[{"content":"给 N100 小主机刷了 Ubuntu Server 作为 Homelab 中的服务器，用于运行各种除下载外的服务（如 Jellyfin、Komga 和 OpenWebUI 等等，Transmission 和 qBittorrent 运行在 NAS 中）。将存、算、网彻底分离到 NAS、服务器和路由器三台不同的物理机上。彻底避免了 all in boom 的可能。\nUbuntu 系统不自带显卡驱动，为了让 Jellyfin 能够硬件解码需要手动安装 Intel i915 驱动：\n1 2 sudo apt update sudo apt install -y intel-media-va-driver-non-free vainfo i965-va-driver 安装后验证 VAAPI是否正常识别 GPU 并确认设备节点存在：\n1 2 3 4 vainfo # 正常输出应包含 VAProfileH264、VAProfileHEVC 等 ls /dev/dri/ # 应该看到 card0 和 renderD128 如果你跟我一样，使用的是从官网下载的 Ubuntu Server 22.04 LTS，安装驱动后运行 vainfo 应该会报错，/dev/dri/ 目录也不存在。但奇怪的是运行 lsmod | grep i915 能够确认 i915 模块已正常加载，GPU 也被识别了，但 /dev/dri 就是不存在。\n遂查看内核日志：\n1 dmesg | grep -iE \u0026#34;drm|dri|i915|error\u0026#34; 日志中有一行：\n1 sof-audio-pci-intel-tgl 0000:00:1f.3: init of i915 and HDMI codec failed 显示 i915 初始化失败，导致 /dev/dri 没有被创建。同时连锁反应导致音频驱动也在报错。问了 Claude 才明白根本原因是内核 5.15 对 N100 (46d1) 支持不完整。\n运行 uname -r 确认内核版本是5.15。然后运行下列命令升级 HWE 内核：\n1 2 3 sudo apt update sudo apt install -y linux-generic-hwe-22.04 linux-firmware intel-microcode sudo reboot 重启后问题解决。启动容器时记得让容器挂载 GPU 设备：\n1 2 3 4 5 6 7 8 9 10 11 12 13 docker run -d \\ --name jellyfin \\ --restart unless-stopped \\ --network host \\ --device /dev/dri/renderD128:/dev/dri/renderD128 \\ --device /dev/dri/card0:/dev/dri/card0 \\ --group-add 109 \\ -v path_to_jellyfin_conf:/config \\ -v path_to_jellyfin_cache:/cache \\ -v path_to_jellyfin_media:/media \\ -e TZ=Asia/Shanghai \\ jellyfin/jellyfin:latest # group-add 109 中的109通过getent group render获取 启动后在 控制台 -\u0026gt; 播放 -\u0026gt; 转码 菜单中作如图配置\n大功告成！\n","date":"2026-04-03T13:50:00Z","image":"/p/jellyfin_ubuntu/cover.png","permalink":"/p/jellyfin_ubuntu/","title":"N100 主机上 Ubuntu 22.04 系统配置 Jellyfin 硬解教程"},{"content":"限流（Rate Limiting）是服务端的核心防护机制，主要有两种经典策略：令牌桶（Token Bucket） 和 滑动窗口（Sliding Window）。\n先来看 Claude 生成的一个交互式演示 HTML，形象地体现出了上述两种方案的区别（推荐使用 Firefox、 Google Chrome 等浏览器查看）：\n令牌桶（Token Bucket）\n以固定速率往桶里补充令牌；每次请求消耗一枚令牌，桶空则拒绝。允许短时突发（桶满时）。\n桶容量 8 补充速率 2/s 发送请求 突发 ×5 重置 8当前令牌数 0通过请求 0拒绝请求 令牌桶（蓝=有令牌，灰=空位） 请求历史 · ●通过 ●拒绝 滑动窗口（Sliding Window）\n维护一段时间内的请求记录；窗口随时间向右滑动，超出的旧请求自动丢弃，只统计窗口内的计数。\n窗口大小 5s 限额 5 发送请求 突发 ×5 重置 0窗口内请求数 0通过请求 0拒绝请求 滑动窗口（蓝色区域 = 窗口范围） 请求历史 · ●通过 ●拒绝 令牌桶 核心思路：一个有容量上限的桶，系统以固定速率向桶中补充令牌。每次请求到来时取走一枚令牌，桶空则拒绝。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 import time import threading class TokenBucket: def __init__(self, capacity: int, refill_rate: float): self.capacity = capacity # 桶最大容量 self.refill_rate = refill_rate # 每秒补充令牌数 self.tokens = float(capacity) # 初始满桶 self.last_refill = time.monotonic() self.lock = threading.Lock() def _refill(self): now = time.monotonic() elapsed = now - self.last_refill added = elapsed * self.refill_rate self.tokens = min(self.capacity, self.tokens + added) self.last_refill = now def allow(self, cost: int = 1) -\u0026gt; bool: with self.lock: self._refill() if self.tokens \u0026gt;= cost: self.tokens -= cost return True return False # 使用示例 bucket = TokenBucket(capacity=10, refill_rate=2) # 桶容量10，每秒补充2个 def handle_request(): if bucket.allow(): return \u0026#34;200 OK\u0026#34; else: return \u0026#34;429 Too Many Requests\u0026#34; 关键特性：允许短时突发——当桶满时连续请求可以瞬间打出 capacity 个，之后降回到 refill_rate 的吞吐速率。适合对突发流量宽容、对长期速率有约束的场景（如 API 限速）。\n滑动窗口 核心思路：维护一个时间窗口，记录窗口内每个请求的时间戳。每次新请求到来前，先淘汰所有\u0026quot;已过期\u0026quot;的旧记录，再判断当前窗口内的请求数是否超限。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 import time import collections import threading class SlidingWindowLimiter: def __init__(self, limit: int, window_seconds: float): self.limit = limit self.window = window_seconds self.timestamps = collections.deque() # 有序队列存时间戳 self.lock = threading.Lock() def allow(self) -\u0026gt; bool: with self.lock: now = time.monotonic() cutoff = now - self.window # 淘汰窗口外的旧请求（队头是最早的） while self.timestamps and self.timestamps[0] \u0026lt;= cutoff: self.timestamps.popleft() if len(self.timestamps) \u0026lt; self.limit: self.timestamps.append(now) return True return False # 使用示例 limiter = SlidingWindowLimiter(limit=100, window_seconds=60) # 60秒内最多100次 def handle_request(): if limiter.allow(): return \u0026#34;200 OK\u0026#34; else: return \u0026#34;429 Too Many Requests\u0026#34; 与固定窗口计数器的区别：固定窗口在两个窗口交界处会出现\u0026quot;双倍流量\u0026quot;漏洞（窗口末尾 + 新窗口开头各来一批），滑动窗口通过精确追踪时间戳彻底解决了这个问题。\n一些思考 分布式场景下的性能问题 单机内存实现在多实例部署时会失效，需要借助 Redis 做共享状态。\n令牌桶用 Redis：用 Lua 脚本保证原子性，避免 TOCTOU 竞争：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 -- token_bucket.lua local key = KEYS[1] local capacity = tonumber(ARGV[1]) local refill_rate = tonumber(ARGV[2]) local cost = tonumber(ARGV[3]) local now = tonumber(ARGV[4]) local bucket = redis.call(\u0026#39;HMGET\u0026#39;, key, \u0026#39;tokens\u0026#39;, \u0026#39;last_refill\u0026#39;) local tokens = tonumber(bucket[1]) or capacity local last_refill = tonumber(bucket[2]) or now -- 补充令牌 local elapsed = now - last_refill tokens = math.min(capacity, tokens + elapsed * refill_rate) if tokens \u0026gt;= cost then tokens = tokens - cost redis.call(\u0026#39;HMSET\u0026#39;, key, \u0026#39;tokens\u0026#39;, tokens, \u0026#39;last_refill\u0026#39;, now) redis.call(\u0026#39;EXPIRE\u0026#39;, key, 3600) return 1 -- 允许 else redis.call(\u0026#39;HMSET\u0026#39;, key, \u0026#39;last_refill\u0026#39;, now) return 0 -- 拒绝 end 滑动窗口用 Redis ZSet：利用有序集合天然按时间戳排序的特性：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import redis import time r = redis.Redis() def sliding_window_redis(user_id: str, limit: int, window: int) -\u0026gt; bool: key = f\u0026#34;ratelimit:{user_id}\u0026#34; now = time.time() cutoff = now - window pipe = r.pipeline() pipe.zremrangebyscore(key, \u0026#39;-inf\u0026#39;, cutoff) # 清理过期 pipe.zadd(key, {str(now): now}) # 记录当前请求 pipe.zcard(key) # 计数 pipe.expire(key, window + 1) # 设置 TTL results = pipe.execute() count = results[2] if count \u0026gt; limit: r.zrem(key, str(now)) # 超限则回滚 return False return True 但是每次请求都需要至少一次 Redis 往返（RTT），在高并发场景下都可能成为瓶颈，两个方案分别可以这样优化：\n令牌桶可以一次请求预申请多个令牌：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 class BatchTokenBucket: def __init__(self, redis_client, key, capacity, refill_rate, prefetch_size=20): self.r = redis_client self.key = key self.capacity = capacity self.refill_rate = refill_rate self.prefetch_size = prefetch_size # 一次从 Redis 取多少令牌 self._local_tokens = 0 self._lock = threading.Lock() # Lua 脚本：原子地从 Redis 桶里取走 N 个令牌 LUA_SCRIPT = \u0026#34;\u0026#34;\u0026#34; local key = KEYS[1] local capacity = tonumber(ARGV[1]) local rate = tonumber(ARGV[2]) local cost = tonumber(ARGV[3]) local now = tonumber(ARGV[4]) local data = redis.call(\u0026#39;HMGET\u0026#39;, key, \u0026#39;tokens\u0026#39;, \u0026#39;ts\u0026#39;) local tokens = tonumber(data[1]) or capacity local ts = tonumber(data[2]) or now tokens = math.min(capacity, tokens + (now - ts) * rate) if tokens \u0026gt;= cost then tokens = tokens - cost redis.call(\u0026#39;HMSET\u0026#39;, key, \u0026#39;tokens\u0026#39;, tokens, \u0026#39;ts\u0026#39;, now) redis.call(\u0026#39;EXPIRE\u0026#39;, key, 3600) return cost else local available = math.floor(tokens) if available \u0026gt; 0 then redis.call(\u0026#39;HMSET\u0026#39;, key, \u0026#39;tokens\u0026#39;, 0, \u0026#39;ts\u0026#39;, now) return available end return 0 end \u0026#34;\u0026#34;\u0026#34; def _fetch_tokens(self, amount) -\u0026gt; int: \u0026#34;\u0026#34;\u0026#34;一次从 Redis 取走 amount 个令牌，返回实际取到的数量\u0026#34;\u0026#34;\u0026#34; script = self.r.register_script(self.LUA_SCRIPT) return int(script(keys=[self.key], args=[self.capacity, self.refill_rate, amount, time.time()])) def allow(self) -\u0026gt; bool: with self._lock: if self._local_tokens \u0026gt; 0: self._local_tokens -= 1 return True # 纯本地，无 Redis # 批量预取 got = self._fetch_tokens(self.prefetch_size) if got \u0026gt; 0: self._local_tokens = got - 1 return True return False 也可以把一个大桶拆成多个小桶，每个实例随机打到一个分片，分散写压力，即令牌桶分片：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import random class ShardedTokenBucket: def __init__(self, redis_client, key, capacity, refill_rate, shards=10): self.r = redis_client self.base_key = key self.shards = shards # 每个分片分配 capacity/shards 个令牌，速率也均分 self.shard_capacity = capacity // shards self.shard_rate = refill_rate / shards def allow(self) -\u0026gt; bool: shard = random.randint(0, self.shards - 1) key = f\u0026#34;{self.base_key}:shard:{shard}\u0026#34; # 对单个分片执行 Lua 脚本，分散了 Redis 的写热点 return self._lua_allow(key, self.shard_capacity, self.shard_rate) 而滑动窗口可以在每个服务实例内存里维护一个本地小窗口，批量向 Redis 预申请配额，而不是每次请求都打 Redis：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 import threading import time class LocalBatchSlidingWindow: def __init__(self, redis_client, key, global_limit, window, local_quota=50, sync_interval=0.1): self.r = redis_client self.key = key self.global_limit = global_limit self.window = window self.local_quota = local_quota # 每次向 Redis 预申请的配额 self.sync_interval = sync_interval # 多久同步一次 self._local_remaining = 0 # 本地剩余配额 self._lock = threading.Lock() self._last_sync = 0 def _fetch_quota_from_redis(self) -\u0026gt; int: \u0026#34;\u0026#34;\u0026#34;向 Redis 申请一批配额，返回实际获得的数量\u0026#34;\u0026#34;\u0026#34; now = time.time() cutoff = now - self.window pipe = self.r.pipeline() pipe.zremrangebyscore(self.key, \u0026#39;-inf\u0026#39;, cutoff) pipe.zcard(self.key) results = pipe.execute() current_count = results[1] available = self.global_limit - current_count quota = min(self.local_quota, max(0, available)) if quota \u0026gt; 0: # 批量预占：插入 quota 个未来时间戳作为占位符 batch = {f\u0026#34;_pre_{now}_{i}\u0026#34;: now for i in range(quota)} self.r.zadd(self.key, batch) self.r.expire(self.key, int(self.window) + 1) return quota def allow(self) -\u0026gt; bool: with self._lock: if self._local_remaining \u0026gt; 0: self._local_remaining -= 1 return True # 纯内存操作，无 Redis 开销 # 本地配额耗尽，去 Redis 补充 quota = self._fetch_quota_from_redis() if quota \u0026gt; 0: self._local_remaining = quota - 1 return True return False 当然，无论用哪种限流策略，都可以在流量进入服务之前就拦截，用 lua-resty-limit-traffic 在 Nginx 里做内存级令牌桶，完全不走应用层和 Redis，延迟降到微秒级：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # nginx.conf lua_shared_dict my_limit_req_store 100m; # 共享内存，所有 worker 共用 location /api/ { access_by_lua_block { local limit_req = require \u0026#34;resty.limit.req\u0026#34; local lim = limit_req.new(\u0026#34;my_limit_req_store\u0026#34;, 2000, 1000) -- 2000 req/s，允许 1000 的突发 local delay, err = lim:incoming(ngx.var.binary_remote_addr, true) if not delay then if err == \u0026#34;rejected\u0026#34; then ngx.exit(429) end end } proxy_pass http://backend; } 那么问题来了。\n为什么不直接在 Nginx 侧用 OpenResty 实现令牌桶或滑动窗口限流，而要在后端服务中做呢？ 原因有二：\n1. Nginx 层访问 Redis 的代价\nNginx 本来是纯内存、零阻塞的热路径。一旦每个请求都要等 Redis，Nginx 的延迟优势就消失了。而且 Nginx 的 cosocket（非阻塞网络）虽然不阻塞 event loop，但连接池管理、错误处理、Redis 超时降级的复杂度全部需要用 Lua 手写，运维成本很高。\n这里我个人还在尝试中遇到一个坑，OpenResty 是无法像 Python、Java 等程序一样直接读取环境变量的。所以不想在 nginx.conf 或者 systemd 的配置中明文配置 Redis 口令的话，需要在配置中配置加密后的口令，并配合外部解密脚本使用，非常麻烦。\n2. 限流逻辑往往依赖业务语义，Nginx 层拿不到\n1 2 3 4 5 6 7 8 9 10 -- Nginx 能拿到的信息 ngx.var.remote_addr -- IP ngx.req.get_headers() -- HTTP 头 ngx.var.uri -- 路径 -- 但限流规则经常需要 user_id -- 需要解析 JWT / Session，Nginx 层做很重 account_tier -- 需要查数据库（免费用户100次/天，付费用户10000次/天） api_key_quota -- 需要关联业务数据 request_cost -- 不同操作消耗不同令牌数（上传视频 vs 查询列表） 解析 JWT、查用户等级这些事放在 Nginx Lua 里做，实际上是把业务逻辑下沉到了基础设施层，职责严重混乱。\n两层限流不是替代关系，而是各司其职。所以工程实践上的共识是：Nginx 做防御性的粗粒度限流（不访问任何外部存储），应用层做业务语义的精细限流（Redis 协调全局状态），两层互补，而不是用一层替代另一层。\n鉴权应该在限流前做吗？ 一般情况下，不建议把鉴权放在限流之前，顺序通常是：\n先做基础限流 → 再做鉴权 → 再做精细限流（按用户）\n其原因是鉴权本身有计算成本，而限流的目的之一就是保护这些昂贵操作。\n鉴权通常涉及：\n解析并验证 JWT 签名（RSA/ECDSA 非对称加密，CPU 密集） 查询 Session Store / Redis 查数据库验证 API Key 是否有效、是否被吊销 如果攻击者用 10 万 QPS 打过来，先做鉴权意味着每个请求都要跑一次 JWT 验证或 Redis 查询，限流反而成了摆设。先限流把绝大多数请求在入口拦截，鉴权只面对漏过来的合法流量。\n那问题又来了。\n如果攻击者用 10 万 QPS 打过来，那正常用户的请求也会被限流吧？ 关键在于让正常用户尽早建立身份。攻击者打的是匿名流量，而已认证的正常用户带着身份，两个配额池完全独立。攻击者把匿名池打满，不影响已登录用户的配额。同时配合分层漏斗、特征识别等手段，而非直接粗暴根据 IP 限流（公司、学校、运营商 NAT 后面可能有成千上万人，共享出口 IP 的正常用户也被拦）。\n当攻击规模极大时，识别已经来不及，要在架构层面做隔离。例如大厂（CloudFlare、AWS Shield等）对抗 DDoS 的核心思路就是流量按身份路由到不同集群，把攻击流量和正常流量物理隔离。攻击者打垮匿名集群，登录用户集群以及 API Key 集群（按合同配额）完全不受影响。\n","date":"2025-12-01T20:16:00Z","image":"/p/rate_limit/cover.webp","permalink":"/p/rate_limit/","title":"限流的实现和问题"},{"content":"玩过不少游戏，其中剧本和故事深度超越《DS1》的虽然不多，但也的确有，游戏性比《DS1》更好的更是不少。但纵观其他游戏的游玩经历，很难有一瞬间能够超越在《DS1》中翻越一座包含BT区的陡峭山峰后，突然豁然开朗，发现前有绝景，同时耳边响起 Low Roar 或者 Silent Poets 空灵的歌声的体验了。所以真的很难忍得住不在第一时间在首发期间花了我接近 500 大洋入手了《DS2》，并花了一个多月 100+ 小时白金。（此时二手盘已跌至 300 以下……）\nGameplay 方面，和前作一样，还是主要由两部分组成，即游戏的主题之一，绳子和棍棒。绳子代表着通过送货连接各个节点，棍棒代表通过战斗扫清连接过程中的种种障碍。\n战斗部分，虽然说不上突破，但比起前作一定是有进步的。主线中的战斗含量显著增加，同时新增了机甲这种敌人，和前作与拔叔的战斗贡献了三场最经典的战斗不同，个人认为本作中南观测保卫战和连接终堡前的大桥一战两次机甲战比起和 Neil 的三场战斗更加过瘾，真的有种 《合金装备》 精神续作的感觉了。遗憾的是，这两场战斗大概率会在一个小时内先后连续发生，对于喜欢的玩家有点囫囵吞枣，而对于不喜欢的玩家可能就有点在过量了。战斗的量增多的同时武器种类也增多了，战斗做的越来越花哨了，不过就个人体验而言，消音狙、电波拉和各种榴弹发射器就已经足够应付各种人类和机甲了。本作简化了子弹类型和尸体处理这些前作战斗系统中比较麻烦的部分，这点算个不小的加分项。BT 战的主要更新在于巨型BT战中新加入的宝可梦机制，喜欢特摄怪兽片的玩家一定会喜欢，战斗时播放的也正是老特摄片 BGM（战斗很喜欢，但说实话个人觉得这个BGM还是有点出戏了）。\n送货部分有优点也有缺点，《DS2》中大大降低了从 A 点到 B 点的难度，滑索、卡车均迎来史诗级加强，还出现了棺材板这种逆天载具，同时各种传送也大大简化了送货的流程。本作中最快捷的升星刷赞方式变成了用DHV麦哲伦四处接单然后潜行消灭一个营地刷出额外点赞再批量用 DHV 麦哲伦送货。看上去似乎更简单了，但另一方面，出于平衡性考量，本作的货物变得更易碎了，稍微撞个小石头，甚至从 DHV 上下来的时候急了点货物就 5% 了，这导致玩家在送货过程中 SL 次数明显增加，同时个人明显感觉刷白金时 NPC 升星所需要的派单量更多，后期为了某几个节点甚至还需要挂机等单……5025 年的 3A 游戏任务更新居然需要等待实际游戏时间的六个小时这你敢信？\n剧情方面，玩的时候就觉得不如前作，看视频重温了一下《DS1》的剧情更是意识到《DS2》的剧情有多贫瘠。《DS1》的剧情是在游戏过程中一步步揭开的，每个角色在人设鲜明的同时一定跟主线相扣，而《DS2》在进入最终剧情前，几乎是流水账式的介绍了各个新队友的背景，然而像 Rainy、Tarman 和 Dollman 这样的角色，可以说去掉对于主线甚至不会有什么决定性的影响，完全只是为了契合某些设定的工具人，当然值得肯定的是，男女主 Sam 和 Fragile 的塑造还是在线的。这就使得在最终剧情开始时，主线故事其实才刚刚开始，而之前玩家就是一无所知地看着一堆谜语人说哑谜。而最终剧情开始后，玩家又要一次性在连续的几个小时内接受很大的信息量，玩家可能还没有消化 Fragile 和 Tomorrow 星际穿越般令人唏嘘的剧情，就又被导演拽进了舔狗 Neil 的狗血 NTR 剧情……\n另一方面，《DS2》中的彩蛋和搞怪部分明显增多，可能对于部分玩家来说过于抽象比较出戏，但我还蛮喜欢这种的。\n同时，本作的节奏也很难不吐槽一下，游戏从奖杯数量看来一共有十六章（实际还有一章是用于通过后继续游玩的后日谈），而前文所说的“最终剧情”竟然在第九章就开始了，剧情开始后，长时间 DHV 麦哲伦无法传送，各种支线也无法推进，只能硬着头皮一直将主线推到底，为什么不能将主线更多地提前穿插在之前的游戏中呢？更离谱的是，游戏中甚至有两个 NPC 是主线结束后才能连接的、而像幽灵猎手这样的支线也要求必须在主线完成后推进，包括从第九章开始连接的 NPC 在升星后给到的道具，理论上也只有主线结束后才能拿到。小岛发推说有 80% 的玩家通关后还在继续玩，我想说这不是废话吗，谁让你把可能有 15% 左右的游戏内容都塞在主线通关后啊……而且我相信大多数玩家都会喜欢在进入最终剧情前那种 Sam 到处送货，但回到 DHV 麦哲伦上总有一堆伙伴等你回家的感受，但后日谈中人走茶凉的背景，很难不降低玩家的送货体验……\n最后说说音画体验吧。Decima 引擎是真的牛逼，你很难想象 PS5 Slim 那孱弱的性能上能跑出基本是本世代至今画质最精细的游戏。稍显可惜的是《DS2》中虽然多了像沙漠和石林这样的地貌，但个人感觉景色还是不如在冰岛取景的《DS1》。音乐方面依然是一贯的牛逼，不过第一部是先有的 Low Roar 的音乐，音乐作为灵感反过来促使小岛创作了游戏，整个游戏像是为 Low Roar 做的 MV，而本作明显是 Woodkid 为游戏专门制作的 BGM，虽然也不乏好听的歌曲，但远不如《DS1》的 OST 惊艳。\n总之，有《DS1》珠玉在前，《DS2》的确很难超越前作的惊艳，毕竟前作对于当时的玩家来说，实在太前卫了，但就个人来说，《DS2》已经满足了作为老玩家对于续作 99% 的期待了。\n最后的最后，最近经常刷到岛哥哥和各种欧美影视圈的人士（很多都是本作中的脸模）一起出席各种活动，这让我想起五六年前，疫情期间在家里玩《DS1》时，围观的母上发现游戏原来也可以不是打打杀杀或者消消乐、游戏也可以有这么美的景色以及游戏也可以传达像“连接”这样深刻的主题。那个时候黑神话还没发布第一个震惊业界的 PV，国内甚至还没出现一个现象级的二游（粥应该刚开服不久）。而如今米家某二字手游以及《黑神话：悟空》的巨大成功让游戏在国内的认可度越来越高、舆论越来越好，甚至都能频繁上央视了，不过明显很大程度上这是因为国内玩家太多，无论手游还是 3A 均有巨大的市场，让掌握了话语权的资本市场看到了 Gen Z 情绪体验的巨大商业价值，这当然是好事，但我同时也希望能有更多像小岛一样的人士，能用作品和个人的影响力让大众不止从从商业性的角度、也从艺术性的角度来看待游戏。\n","date":"2025-08-11T10:29:00Z","image":"/p/ds2/cover.jpeg","permalink":"/p/ds2/","title":"《死亡搁浅2：冥滩之上》的得与失"},{"content":"Subtitle Renamer 是我日常使用的字幕重命名工具，非常好的解决了 Jellyfin / mpv 等视频播放软件仅识别与视频文件同名的字幕文件的痛点。\n在使用中发现一个问题，经常会出现无法识别 .ass 和 .ssa 格式字幕的问题，而其他格式的字幕（如 .srt ）则从来没有遇到过这种情况。而这些无法识别的字幕，在我所用的所有视频播放软件中都能正常渲染。同时，去仓库的 Issue 中查看，发现已经有人遇到过这个问题。\n拉取源码后，找到了报错的代码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 def assSubtitle(file_name): # 检测文本编码 encoding = subEncoding(file_name) with open(file_name, \u0026#34;r\u0026#34;, encoding=encoding) as file: result = ass.parse(file).events # 提取正文内容 subtitle = [] for item in result: # 转义样式中的斜杠 new_item = item.text.replace(\u0026#34;\\\\\u0026#34;, \u0026#34;\\\\\\\\\u0026#34;) # 匹配 {} 之外内容 ass_pattern = r\u0026#39;\\{[^{}]*\\}\u0026#39; matches = re.sub(ass_pattern, \u0026#39;\u0026#39;, new_item) # 排除单字的特效字幕 if len(matches) == 1: continue # 排除空内容 if not matches: continue subtitle.append(matches) return subtitle 问题在于 result = ass.parse(file).events 这一行。 ASS/SSA 格式没有一个严格的官方标准，Python 的 ass 库对 .ass 和 .ssa 文件的格式解析比较严格，其本质是一个 parser ，遇到不符合预期的内容就会抛异常，而实际播放器则非常宽容，普遍采取\u0026quot;尽力解析、忽略错误\u0026quot;的策略。\n让 AI 总结了以下可能会造成 ass 库报错的场景：\n字段数量不匹配 — Dialogue 行的字段数与 Format 行定义的不一致（多了或少了逗号）。播放器会截断或补空，但 parser 直接报错。 编码问题 — 文件实际是 GBK/GB2312/Shift-JIS 但没有 BOM 或声明，库默认按 UTF-8 读取就会炸。播放器通常会自动探测编码。 非标准的 section 或字段 — 比如 [Aegisub Project Garbage]、自定义的 [Fonts]、[Graphics] 等扩展段，以及第一行漏写了 [Script Info] ，或者某些字段名拼写不标准，库不认识就报错。（大部分情况应该属于这种） Style 定义中的非法值 — 比如颜色值格式不规范（\u0026amp;H00FFFFFF 写成 \u0026amp;HFFFFFF）、布尔值用 True/False 而不是 -1/0、字体大小为空等。 行尾/换行符问题 — 混合的 \\r\\n 和 \\n，或者文件末尾缺少换行。 注释和空行 — 文件中夹杂着 ; 开头的注释或意外的空行，parser 不处理就崩了。 其中第三种情况在各个字幕组制作的字幕文件中非常普遍，毕竟字幕组数量庞大，字幕制作者也可能没有这方面的意识，主打一个“能用就行”。为了兼容这么一部分数量庞大的非标字幕文件，尝试在 ass 报错后尝试使用正则来解析字幕文件：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 def assSubtitle(file_name): # 检测文本编码 encoding = subEncoding(file_name) try: # 原 ass.parse 逻辑 except Exception as e: # 如果ass.parse失败，使用正则表达式解析 print(f\u0026#34;ass.parse解析失败，使用正则表达式解析: {e}\u0026#34;) subtitle = [] try: with open(file_name, \u0026#34;r\u0026#34;, encoding=encoding) as file: in_events_section = False for line in file: line = line.strip() # 检查是否进入了[Events]部分 if line == \u0026#34;[Events]\u0026#34;: in_events_section = True continue # 检查是否进入了其他部分 if in_events_section and line.startswith(\u0026#34;[\u0026#34;): in_events_section = False # 只处理Events部分的Dialogue行 if in_events_section and line.startswith(\u0026#34;Dialogue:\u0026#34;): # 分割并获取最后一个部分(对话内容) parts = line.split(\u0026#39;,\u0026#39;, 9) # 最多分割9次，确保最后一个元素包含全部对话内容 if len(parts) \u0026gt;= 10: text = parts[9] # 转义样式中的斜杠 text = text.replace(\u0026#34;\\\\\u0026#34;, \u0026#34;\\\\\\\\\u0026#34;) # 移除花括号内的ASS样式代码 clean_text = re.sub(r\u0026#39;\\{[^{}]*\\}\u0026#39;, \u0026#39;\u0026#39;, text) # 移除多余空白 clean_text = clean_text.strip() # 排除单字和空内容 if len(clean_text) \u0026gt; 1 and clean_text: subtitle.append(clean_text) return subtitle except Exception as e: print(f\u0026#34;正则解析失败: {e}\u0026#34;) raise e 搞定。这里保持原来的 ass.parse 逻辑是因为经过实际测试，处理同样的标准字幕文件，使用 ass.parse 比正则解析更快。\n","date":"2025-04-25T14:34:00Z","image":"/p/ass/cover.jpeg","permalink":"/p/ass/","title":"避免使用 Python ass 库处理 ASS/SSA 格式的字幕文件"},{"content":"之前一直在 OpenWrt 主路由上跑 Transmission 以及一揽子服务，后来大概是各种写日志把系统盘坏了，结果不仅服务没了，家里也上不了网了。\n为了保障网络的稳定性，将 OpenWrt 换成了刷了固件的华硕硬路由，只安装 fancyss 和 ZeroTier。其余服务全部移到威联通的 NAS 上。\n其他服务运行都正常，唯独 Transmission 从 OpenWrt 编译进固件的应用换到 NAS 上的 Docker 后，连续好几天 50+ 个种子没有任何一点上传。而之前使用 OpenWrt 时，每天固定都有一定上传量。\n先来复习下 PT 的原理：\nPeer 客户端（如 Transmission）通过 HTTP/HTTPS 向 Tracker 发送请求注册（announce）:\n1 GET /announce?info_hash=xxx\u0026amp;peer_id=xxx\u0026amp;port=51413\u0026amp;uploaded=0\u0026amp;downloaded=0\u0026amp;left=xxx\u0026amp;passkey=xxx Tracker 返回：\n1 2 3 4 5 6 7 { peers: [ PeerA IP:Port, PeerB IP:Port, ... ] } 之后 Peer 之间开始建立 P2P 连接，这个过程不再经过 Tracker，数据是点对点直传的。很明显，若设备没有公网地址，除非在下载时主动和其他 Peer 建立了连接，该设备是不可能被动地被其他 Peer 发现并做种的，这也就是为什么我将 50+ 个已经下载完成的种子添加后，几天都没有一点上传的原因。\n那么，为什么同样没有公网 IP，之前 OpenWrt 做种做的飞起呢？\n原因是 IPv6，若客户端通过 IPv6 连接 Tracker 或在 announce 中上报了 IPv6 地址，Tracker 也会拿到其 IPv6 地址，从而让其他 Peer 可以通过公网 IPv6 发现该 Peer。威联通 NAS 出于安全考量，默认关闭了 IPv6，可通过 控制台 → 网络与虚拟交换机 → 网络适配器 → 设定 将所有网卡的 IPv6 打开，如图：\n连接类型具体选哪个，看你路由器 RA 的配置模式：\nSLAAC（无状态地址自动配置）：最常见，客户端根据路由器通告的前缀自己生成地址。国内运营商及商用路由器绝大多数都是这种方式。 DHCPv6（有状态地址自动配置）：由路由器的 DHCPv6 服务器分配地址，相对少见。根据笔者经验，一般折腾 OpenWrt 时会选择配置 DHCPv6 便于做防火墙规则、端口转发、DNS 绑定等。 这里配置好之后一般就能够获取到 IPv6 地址了。\n注意家用路由器一般会默认启用 IPv6 防火墙，为了外部 Peer 能正常连接到家庭网络中的 NAS，需在防火墙上为 NAS 获取到的 IPv6 地址的 51413 端口放行 TCP 和 UDP 的流量。\n不过如果你和我一样，路由器上跑了 fancyss 之类的代理服务，虽然获取到了地址，但很可能 DNS 解析会存在问题。\n1 2 curl -6 ifconfig.co # 返回 curl: (6) Could not resolve host: ifconfig.co 还需要在 fancyss 这类服务的设置中找一下，关掉类似 \u0026ldquo;过滤 AAAA 记录\u0026rdquo; 或 \u0026ldquo;DNS 过滤 IPv6\u0026rdquo; 的开关，避免 AAAA 记录被过滤掉。也可以直接修改 NAS 上的 DNS 服务器为公共 DNS，绕过路由器的 DNS（但这可能也绕过 AdGuard Home 等服务）。\n主机 IPv6 已经通了，但是运行 docker exec transmission curl -6 ifconfig.co 在容器中测试 IPv6 连通性仍然失败。\n原因是 Transmission 官方文档中给出的容器启动方式中，容器的网络模式是 bridge，容器默认没有 IPv6。最简单的解决办法是改成 host 模式，容器直接共享宿主机网络，IPv6 立刻可用。\n1 2 3 4 5 6 7 8 9 10 11 12 13 docker run -d \\ --name=transmission \\ --network=host \\ -e PUID=1000 \\ -e PGID=100 \\ -e TZ=Asia/Shanghai \\ -e USER=root \\ -e PASS=your_password \\ -e WHITELIST=192.168.5.* \\ -v /path_to_transmission_conf:/config \\ -v /path_to_downloads:/downloads \\ --restart unless-stopped \\ lscr.io/linuxserver/transmission:latest 停止容器，修改参数后重新启动，终于一切正常，硬盘马上开始炒豆子了。打开正在做种的种子，peers 列表中已经有 IPv6 地址的 Peer 了。\n","date":"2025-03-27T09:36:00Z","image":"/p/transmission_ipv6/cover.webp","permalink":"/p/transmission_ipv6/","title":"威联通 NAS Transmission 做种配置"},{"content":"Python 中有四种将字符串解析为对象的方法，除了常见的反序列化工具 json.loads、ujson.loads，还有 eval、ast.literal_eval 两个函数。下表概括了四者的差异：\njson.loads ujson.loads eval ast.literal_eval 来源 标准库 json 三方库 ujson 标准库 ast 标准库 ast 安全性 安全 安全 不安全 安全 输入 严格 JSON 严格 JSON 任意 Python 语句 Python 字面量 支持类型 JSON 6种类型 JSON 6种类型 全部 Python 类型 str/int/list/dict/tuple/set 单引号字符串 不支持 不支持 支持 支持 性能 中 最快 较慢 较慢 json.loads — 标准 JSON 解析 Python 内置 json 模块用纯 Python 实现了一个递归下降 JSON 解析器。CPython 3.x 中核心部分（_json 模块）实际是 C 扩展加速，但逻辑上严格遵循 RFC 8259。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import json # 基本用法 data = json.loads(\u0026#39;{\u0026#34;key\u0026#34;: \u0026#34;value\u0026#34;, \u0026#34;num\u0026#34;: 42, \u0026#34;flag\u0026#34;: true}\u0026#39;) # 自定义对象钩子 def parse_decimal(d): return {k: float(v) if isinstance(v, str) else v for k, v in d.items()} data = json.loads(\u0026#39;{\u0026#34;price\u0026#34;: \u0026#34;3.14\u0026#34;}\u0026#39;, object_hook=parse_decimal) # 严格的格式要求（常见坑） json.loads(\u0026#34;{\u0026#39;key\u0026#39;: \u0026#39;val\u0026#39;}\u0026#34;) # ValueError：单引号不合法 json.loads(\u0026#39;{\u0026#34;key\u0026#34;: None}\u0026#39;) # ValueError：None 是 Python 不是 JSON（应该是 null） json.loads(\u0026#39;{\u0026#34;key\u0026#34;: undefined}\u0026#39;) # undefined 不是有效 JSON JSON 与 Python 类型映射：\nJSON Python object dict array list string str number (int) int number (float) float true/false True/False null None 注意点：JSON 的 key 必须是双引号字符串；不支持注释；NaN/Infinity 默认不支持（但可通过 parse_constant 兼容）。\nujson.loads — 超快速 JSON 解析 ujson（UltraJSON）是用 C 写的第三方 JSON 库，底层直接操作内存、避免了 CPython 对象创建的开销，对大型 JSON 文档比标准库快 2～5 倍。\n1 2 3 4 5 6 7 8 9 10 import ujson data = ujson.loads(\u0026#39;{\u0026#34;key\u0026#34;: \u0026#34;value\u0026#34;}\u0026#39;) # API 和 json.loads 基本兼容 ujson.loads(\u0026#39;{\u0026#34;ts\u0026#34;: 1700000000}\u0026#39;) # 但有细微差异： ujson.loads(\u0026#39;{\u0026#34;val\u0026#34;: 1.000000000000001}\u0026#39;) # 浮点精度可能与 json 略有差异 ujson.loads(\u0026#39;NaN\u0026#39;) # 部分版本 ujson 会把 NaN 当合法值处理，而 json 会抛异常 注意，ujson 并非 100% 兼容 json 模块；object_hook 等钩子支持不完整；对某些边缘 Unicode 转义的处理历史上有 bug；生产环境建议锁定版本。\n实际上 json.loads 在多数场景下性能已经足够，无需额外引入 ujson 增加系统复杂度。\neval() — 绝对不要用于外部输入 Python 内置函数，将字符串送入 Python 解释器的代码编译+执行流水线，和直接执行 Python 代码完全等价。\n1 2 3 4 5 6 7 8 # 能用 eval(\u0026#39;{\u0026#34;key\u0026#34;: \u0026#34;value\u0026#34;}\u0026#39;) # 返回 dict eval(\u0026#39;[1, 2, 3]\u0026#39;) # 返回 list # 但不安全 eval(\u0026#34;__import__(\u0026#39;os\u0026#39;).system(\u0026#39;rm -rf /\u0026#39;)\u0026#34;) eval(\u0026#34;open(\u0026#39;/etc/passwd\u0026#39;).read()\u0026#34;) eval(\u0026#34;__import__(\u0026#39;subprocess\u0026#39;).check_output([\u0026#39;whoami\u0026#39;])\u0026#34;) eval() 对不可信输入的任何使用场景都有替代方案，永远不要用于解析外部数据。\nast.literal_eval — 安全的 Python 字面量解析 ast.literal_eval 先用 Python 编译器将字符串解析为 AST（抽象语法树），然后只允许 AST 中出现字面量节点（Constant、List、Dict、Tuple、Set），遇到任何函数调用、名称引用等节点都会抛出 ValueError，不会执行任何代码。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import ast # 比 json.loads 宽松的地方：支持 Python 语法 ast.literal_eval(\u0026#34;{\u0026#39;key\u0026#39;: \u0026#39;value\u0026#39;}\u0026#34;) # ✓ 单引号 OK ast.literal_eval(\u0026#34;[1, 2, 3]\u0026#34;) # ✓ list ast.literal_eval(\u0026#34;(1, 2, 3)\u0026#34;) # ✓ tuple（json 不支持） ast.literal_eval(\u0026#34;{1, 2, 3}\u0026#34;) # ✓ set（json 不支持） ast.literal_eval(\u0026#34;{\u0026#39;a\u0026#39;: None, \u0026#39;b\u0026#39;: True}\u0026#34;) # ✓ None/True/False（Python 风格） ast.literal_eval(\u0026#34;3.14\u0026#34;) # ✓ 数字字面量 ast.literal_eval(\u0026#34;b\u0026#39;bytes\u0026#39;\u0026#34;) # ✓ Python 3 bytes 字面量 # 不支持的 ast.literal_eval(\u0026#34;[x for x in range(10)]\u0026#34;) # ValueError：有表达式 ast.literal_eval(\u0026#34;1 + 2\u0026#34;) # ValueError（3.2以后） ast.literal_eval(\u0026#34;os.getcwd()\u0026#34;) # ValueError：有调用 经典的使用场景是解析 Python repr() 的输出，或来自可信内部系统（但非完全受信任）的配置字符串，解析 set/tuple 等 JSON 无法表达的类型。\n注意：\n对超大嵌套结构（几千层递归）有栈溢出风险（CPython 有递归深度限制），建议在解析前做大小/深度预检。 解析速度比 json.loads 慢 5～10x，不适合高频路径。 Python 3.2 之前 1+2 会返回 3（有一个算术折叠的 bug），之后修复了。 对于大字符串，它实际上会构建完整 AST 再遍历，内存开销比 json 高。 常见混淆场景 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import json, ast # 场景1：Python repr 的 dict（单引号） s = \u0026#34;{\u0026#39;a\u0026#39;: 1, \u0026#39;b\u0026#39;: True}\u0026#34; json.loads(s) # JSONDecodeError ast.literal_eval(s) # ✓ {\u0026#39;a\u0026#39;: 1, \u0026#39;b\u0026#39;: True} # 场景2：含 tuple 的字符串 s = \u0026#34;(1, 2, 3)\u0026#34; json.loads(s) # JSONDecodeError ast.literal_eval(s) # ✓ (1, 2, 3) # 场景3：含 None（JSON 是 null） json.loads(\u0026#39;{\u0026#34;x\u0026#34;: null}\u0026#39;) # ✓ {\u0026#39;x\u0026#39;: None} json.loads(\u0026#39;{\u0026#34;x\u0026#34;: None}\u0026#39;) # JSONDecodeError ast.literal_eval(\u0026#39;{\u0026#34;x\u0026#34;: None}\u0026#39;) # ✓ {\u0026#39;x\u0026#39;: None} # 场景4：高性能 JSON import ujson ujson.loads(\u0026#39;{\u0026#34;a\u0026#34;: 1}\u0026#39;) # ✓ 最快 总结 优先用 json.loads ，不能用就 ast.literal_eval ，出现性能瓶颈就上 ujson.loads ，永远不要用 eval 。\n","date":"2024-08-17T12:21:00Z","image":"/p/deserialization/cover.webp","permalink":"/p/deserialization/","title":"Python 中将字符串解析为对象的四种方法"},{"content":"18年刚上大一的时候入了 PS4，玩的第一个游戏就是因为画风一见钟情的《P5》，三年后，即将本科毕业的我赶在2022年的元旦前（幸运的是，也是《P5S》即将会免前）白金了《P5R》。对于这种游戏时间在 100 小时以上的游戏，我总有一种不写点东西就无法真正为这段时间画上句号的感觉。更何况，对于女神异闻录5这个系列来说，是两个 100 小时。\n首先惯例地来吹一下系列的艺术风格吧。被国产手游抄袭 114514 次的前卫波普式画风和 UI 设计，主角团们时尚的涩谷风穿搭，与游戏配合得天衣无缝、完美缝合了流行、摇滚、电音、爵士和金属等风格的 OST，《P5》的时髦已然溢出屏幕。而《P5R》新增的内容在这方面竟能保持甚至超越前作的水平，新增的 showtime 以及芳泽和双叶的总攻击都令人耳目一新。\n但《P5》最令我印象深刻的一点则是《P5》对抽象隐喻的可视化。如今的游戏和动漫如果想要描绘抽象的心理世界，绕不开的一点就是对其的可视化，毕竟现代的观众早已不会满足于理解成本极高的纯文字的意识流描写。但正如游戏中佑介在试图绘出“欲望”时所遇到的难题一样，想要形象地描写出抽象之物总是困难的，但只要做好了，则会起到很好的艺术效果。《EVA》中将阻碍人与人互相理解的心之壁具象为防御一切的 AT Field，从而将少年成长的暗线与萝卜打怪兽的明线很好地联系起来。对应的，集体无意识这一概念在套了一层荣格心理学皮的《P5》中是贯穿始终的重要线索。这一概念在 ACGN 中并不少见，但却少有《P5》这样将其刻画得如此精妙的——《P5》中，当主角处于日常生活时，集体无意识是乘坐地铁时的一个个驼着背沉迷于手机和书报的人像剪影（这里还得夸一下《P5》对读盘时间和场景过度的处理真是天下第一，所有过场都有相应的动画，且切换过程十分流畅）；而当主角处于印象空间时，集体无意识则化身为一个个张牙舞爪的阴影。当理解印象空间为何物时，相信不止我一人想到了印象派诗人 Ezra Pound 的经典小诗《In a Station of the Metro》：\nThe apparition of these faces in the crowd;\nPetals on a wet, black bough.\n人群中这些面孔幽灵一般显现；\n湿漉漉的黑色枝条上的许多花瓣。\n选取涩谷地铁站这样一个 highly institutionalized 的地点作为印象空间（集体潜意识）的意象，表现出被重复的日常压迫至麻痹的大众意识。地铁站中一个个幽灵般的面孔就像花瓣一样开在黑色枝条般的铁轨旁，幽灵般的面孔和阴影、黑色枝条上的红色花瓣和红黑色的印象空间，可以说《P5》对于印象空间的设计本身就是对这一首小诗的精妙诠释。\n如果说艺术风格上的成功是《P5》独有的，那剧本上的成功则是系列一脉相承的。所有P系列游戏的故事线都是被分成“正常”和“非正常”的两部分的。“正常”的部分大同小异，都是与伙伴建立联系为主的日常事件。“非正常”的事件在《P4G》中是主角团在小镇的连环杀人案中运用超能力破案，在《P5》中则更为王道，主角团甚至可以运用改心的超能力击碎欲望，净化社会，是一个标准的普通人拥有了某种能力而成为 vigilante 并弘扬法外正义的主题。怪盗团的所作所为，是每一个普通人想做却通常没有能力做做的（难怪某维基群体曾以怪团团自称），但也正因如此，《P5》的剧情实际并没有让人产生很强的代入感，与接地气的《P4G》带给玩家的实感不同，《P5》让人有一种《死亡通知单》或是《看门狗》的观感，扮演惩恶扬善的法外正义确实过瘾，但其内容却是可望不可及的，玩家更像是一个旁观者在观赏一场表演，而非事件的亲历者。游戏制作者选择在游戏前半部分使用倒叙，更是加强了这一观感。因此，玩家在游玩时会被代表着“非正常”一侧的战斗，也就是游戏主线所吸引，但在游玩之后无限怀念的却总是代表着“正常”一侧的日常生活。\n正如考试时的贝斯曲 BGM 的标题“Life Goes On”一样，净化社会、拯救世界的英雄竟然同样要为期末考试而焦虑。比起“不正常”的战斗来说，“正常”的日常生活显然更令人有代入感。在游戏的日常中，用时下流行的话来说，你将会跟随主角在名为“东京”的元宇宙中四处游历。而《P5R》更是新增了包括吉祥寺在内的几个重要景点，这使得《P5R》带给玩家一次东京深度游体验，其本身也可以说是一本东京旅游手册。\n而更重要的是，你将会在这些地方，与不同的人产生羁绊。羁绊和友情是 JRPG 永恒的主题，但《P5》的代入感是前所未有的，原因无他，没有人真的去过剑与魔法的世界，但人人都经历过学生生活，也都留下了种种遗憾，又有谁不期望拥有二次青春呢？而《P5》则满足了这一诉求。除开怪盗的身份，主角就是一位普通的高中生，面对着上课走神时老师扔来的粉笔与永远来不及复习的期末考试。但不同于许多人的是，他的周围有一群志同道合、推心置腹的友人，而在“非正常”一侧的战斗中，正是这样一群可靠的伙伴让主角在面对各种敌人都能立于不败之地，这也是战胜明智和狮童的关键所在。同时在与丸喜的斗争中，主角甚至不能说站在正义和道德的一侧，他胜过丸喜的唯有这些羁绊，所谓失道寡助得道多助。除此之外，在学校以外，他也与形形色色的人建立了联系和羁绊，这一切使得主角这一年的高中生活令人无比神往。游戏的最后，主角在三月的春日中走遍东京，依次向每一位友人依依惜别，细心的玩家能够发现，即使是路人 NPC 如街边卖唱的摇滚歌手和地铁站里的乞丐也会向你道别，整个东京都变得活生生的，变得富有感情了。将这样一个泪点安排在最后，《P5》甚至有了一种让人不忍心封盘的魔力。\n或许我已经玩过太多游戏，《P5》中光怪陆离的异世界和超能力已无法太吸引我；或许我已经过了那个年纪，不再会为《P5》王道而又中二的剧情感到热血沸腾。但《P5》的确给了我一年完美的高中生活。许多人说第三学期就是丸喜给主角团带来的一场黄粱美梦，我想，《P5》带给我这个即将结束学生生涯的人的，就是一场名为二次青春的梦吧。\nPS: 最后还是想说一下自己对《P5R》新增内容的看法。首先战斗方面，因为我玩《P5》的时候啥都不懂（连凹P都是玩《P4G》的时候才会的），中途卡关直接选了 safety 难度，所以在《P5R》中再次体验养成和战斗并没有太多重复感。而新加入的钩锁、瞬杀和 showtime 等，确实让游戏难度降低了不少，尤其是瞬杀。 50 多级的时候靠 showtime、一枪倒地和 150 固伤瓶子磨死一个死神之后，后面三个殿堂里的怪除了最后的法芙娜全是直接瞬杀（而且调高难度对此无效），这一定程度上其实消减了游戏性，也让我没有太大动力去凹P，因此个人认为如果瞬杀只能在印象空间中使用可能会更好。其次剧情方面，争议最大的地方则是对明智的洗白以及主角和明智的“cp”。第三学期的最终 Boss 丸喜是一个非典型 Boss，他是一个慈悲又令人同情的理想主义济世者，他可以算是怪盗团所反抗的权威者的一员，但绝不像之前的那些反派那样象征着腐化与邪恶。甚至如前文所说，他似乎还站在正义和道德的那一侧。因此，决定要反抗丸喜对于主角来说是困难的（如果你像我一样尝试过认同丸喜的结局，相信你也会认为这样的世界才是最美好的），然而对于实际控制主角的玩家来说，进入真结局拿到白金杯才是最重要的，所以大部分玩家可能会欣然接受日式热血鸡汤的真结局，而不去进一步去思考丸喜世界的合理性。这时，就像母亲之于双叶、挚友之于杏、田径队之于龙司、恩师之于佑介、父亲之于春、人貌之于 Mona 一样，制作组强行为主角加上了一个类似的让其纠结于是否要反抗丸喜世界的羁绊，也就是亦敌亦友的明智。而明智与主角之间的感情线可以说很二刺猿了，大部分玩家肯定是难以理解这种羁绊的，因此难免会对这种强凑 CP 感到反胃。而一旦接受了这种二刺猿的设定，再将目光放于丸喜上，我觉得第三学期的剧情也绝对堪称对原作的点睛之笔。同时结尾的处理也很巧妙，游戏最后薛定谔的明智将悬念留白，没有冒犯到明智厨或是明智黑，把水端平了。\n","date":"2022-01-03T20:16:00Z","image":"/p/p5r/cover.jpg","permalink":"/p/p5r/","title":"一场名为二次青春的梦"},{"content":"首发就入了实体版，因为种种原因拖到上个月才通关，最近又用了几天才终于白金。时至今日，大家基本上想玩的也都白金了，不感冒的估计之后也不会碰了。关于游戏到底好不好玩、剧情到底牛不牛逼的讨论也很多了，我这里就不多评价了。不过，我相信只要你认真把这个游戏玩通关了，你多少都会有一些感触。那么，下面我就简单说说我最大的几点感触吧。\n父爱和桥梁 小岛一直非常推崇的电影《银翼杀手》系列的原作《仿生人会梦见电子羊吗》中将是否具有共情能力作为判定仿生人的一个标准。本作中小岛关于连接的灵感显然就来源于此，这是一个人与人隔开，离群索居的人逐渐遭到异化。故事之初丧失共情能力的山姆就是一个例子，随着故事的发展，山姆在连接他人的时候，也将自己与他人连接在一起，重新找回了共情能力，其中又以他与拔叔间父子的连接最打动我。然而，说实话，在山姆带洛去焚化厂之前的剧情我是没怎么看懂的。各种设定太过复杂，加之彼时各种媒体访谈还不够齐全，我完全是因为被其游戏性吸引才继续玩下去的，所以很多人说的连接和国家的主题我是丝毫没感受到。但是之后拔叔的剧情确确实实地触动到我了，甚至让我一度认为父爱才是本作的主题（笑）。\n拔叔是谁？真正的 Die-hard Man，无数次在战场上出生入死，一次又一次将部下从死人堆中救出。这样的一个铁血军人，在结婚生子后却发现自己已经无法面对战场了，因为自己有了牵挂，再也不是那个不怕死的硬汉了。然而他真的因此变得懦弱了吗？并不。正如拔叔自己所说，“成为一个父亲，并没有让我感到恐惧，反而使我更加勇敢”。一个深沉地爱着自己家庭的父亲和一个不怕死的军人，他们同样是顶天立地的男人。拔叔还对山姆说“你是我通向未来的桥梁”，这一点也确确实实地传达给了山姆——山姆用自己已故孩子的名字称呼 Bridge Baby，也许在其他人眼中，Bridge Baby 仅仅是连接死者世界的工具，而山姆却把他当做自己连接未来的桥梁。\n私以为这样跨越时间的连接，比起之前送货时地理上的连接更加接近小岛想表现的主题。遗憾的是拔叔相关的剧情基本上完全独立于主线剧情，可以说换成其他的剧情也没有问题，而三次战场的情节，除了节目效果之外，似乎也对剧情没有起到太大的作用。\n卢登斯人与卢登斯精神 游戏中有个设定是在死亡搁浅的大环境下，只有卢登斯人（Homo Ludens）可以将大家连接起来。在本作中，山姆便是这个卢登斯人，但可惜的是这一点并没有在剧情中表现出来，从表面上来看，反而心人的设定更符合卢登斯人。但有一点可以确信，那就是小岛秀夫本人，是百分百的卢登斯人。\n熟悉小岛的人应该知道，小岛是一位特别喜欢社交媒体的人（这点从他设计点赞这个系统上就能看出），每天 25 小时高强度推特冲浪。而推特是什么地方？如果你使用过推特，就会发现其上因为使用人群的复杂性，导致由政治立场和意识形态不合造成的对线频繁发生，就在前不久，国内“爱国”网友出征讨伐泰国人，把“nmsl”当成唯一进攻手段的国人不仅在全网面前丢了个脸，还使友好的中泰关系毁于一旦。而在这样一个戾气奇大的平台上，小岛秀夫的每条推文下却集中了来自各种语言的友好回复，大家用着谷歌翻译进行着交流、玩梗。这不就是现实中的卢登斯人吗？\n如果你常常在 Steam 上购买小众独立游戏，相信你在评论区能见到各种包含不同语言的开发者与玩家之间的友好交流。前段时间看到有新闻说，外国开发者不懂中文语境下的“hxd”“给弟弟买的”等黑话，闹了不少笑话。又如某国产恋爱游戏的开发者因为在回复中提到自己没有恋爱经验，即使游戏只有中文，也收到了来自全世界各地玩家的关心，许多外国友人表示虽然看不懂，但还是买一份来支持一下可怜的开发者。什么是卢登斯？这就是卢登斯。\n在如今这个性别对立、政治对立、滥用举报、人均祖安化的网络背景下，也许只有卢登斯才能将人们连接起来。\n关于游戏类型和分类的一些想法 因为疫情宅家的原因，母上获得了旁观我玩游戏的机会。起初她被拟真的画质和漂亮的风景吸引，看我送了几单货的时候忍不住问了，“你这是什么游戏啊？现在年轻人玩的游戏我已经完全看不懂了”。我突然意识到，在圈外人眼中，也许游戏早已成为了打打杀杀的代名词，不说王者荣耀之流，即使是我之前在她面前玩过的主机游戏诸如《只狼》、《仁王》等也脱离不了这个范畴。而先不论死亡搁浅的 Gameplay 到底优不优秀，但小岛确确实实地开创了一个崭新的拥有完善体系的游戏类型，一个弱化了传统对抗元素的游戏类型。这样的作品，似乎才更加符合第九艺术的定义。\n游戏这个载体，其富含的元素绝对远远多于文字、图像和视频，有像《Fate/Grand Order》那样抽卡养成的游戏，有像《英雄联盟》那样接近于体育项目的电竞游戏，也有死亡搁浅这样的电影游戏。大家绝不会把说明书和小说相提并论、也不会把短剧和文艺电影相提并论，但在很多人眼中游戏这个概念却已经被某一种类型的游戏所代替，我想这也是死亡搁浅得到IGN低分的一大原因。身边有个玩金属的朋友，总是对周围的人表示他们做的音乐不是快抖上的神曲，而是 Hardcore 的啥啥啥。我想，陈星汉暴言“氪金游戏的设计者应该进监狱”以及小岛秀夫对 IGN 评 6.8 分表示不解时的想法，也许和我这位朋友不谋而合吧。\n游戏的分类不是提倡鄙视链，而是一个使人们能够更加客观评价游戏的手段，可悲的是，目前这种对游戏的分类意识还不甚普及。\n最后，总的来说，如果你能熬过前两章的话，死亡搁浅绝对是值得一玩的游戏。\n","date":"2020-04-18T00:55:00Z","image":"/p/ds/cover.jpg","permalink":"/p/ds/","title":"白金《死亡搁浅》后的一些感触"}]