package dnfbridge import ( "fmt" "os" "path/filepath" "strconv" "strings" "sync" "time" ) // cutsceneTriggerFileName 是客户端补丁轮询的触发文件。 // // 机制(已实测可用,见 client-patch-src/CUTSCENE-FORCE-NOTES.md): // 客户端补丁每 250ms 检查客户端目录下的这个文件,读到数字 N 就调用客户端自己的 // 影片播放器播放 `Video/movie/origin_cinematic_NN.avi`,然后删除文件。 // // 服务端与客户端同机,所以开区任务完成时只要写这个文件,就能让客户端**当场** // 播放该区域的章节过场 —— 不依赖 op1378 的播放判定(那条路径只在角色选择时生效, // 城镇场景重建时客户端会直接忽略)。 const cutsceneTriggerFileName = "90CN_play_cutscene.txt" // resolveClientCutsceneTriggerPath 解析触发文件路径。 // // 优先 DNF90_CLIENT_DIR 环境变量;否则从服务端工作目录(runtime)上溯两级找 Client: // // \Server\runtime -> \Client func resolveClientCutsceneTriggerPath() (string, error) { if dir := strings.TrimSpace(os.Getenv("DNF90_CLIENT_DIR")); dir != "" { return filepath.Join(dir, cutsceneTriggerFileName), nil } working, err := os.Getwd() if err != nil { return "", err } candidates := []string{ filepath.Join(working, "..", "..", "Client"), filepath.Join(working, "..", "Client"), filepath.Join(working, "Client"), } for _, dir := range candidates { if info, statErr := os.Stat(dir); statErr == nil && info.IsDir() { return filepath.Join(dir, cutsceneTriggerFileName), nil } } return "", fmt.Errorf("client directory not found from %s (set DNF90_CLIENT_DIR)", working) } // triggerClientCutsceneFile 写触发文件,让客户端播放 sequence 对应的章节影片。 func triggerClientCutsceneFile(sequence int) error { if sequence <= 0 || sequence > 99 { return fmt.Errorf("invalid cutscene sequence %d", sequence) } path, err := resolveClientCutsceneTriggerPath() if err != nil { return err } return os.WriteFile(path, []byte(strconv.Itoa(sequence)+"\n"), 0o644) } // triggerClientCutsceneFileDelayed 延迟写入触发文件。 // // 开区任务完成的那一瞬,客户端正在重建城镇场景(op22→op23→op356→同区域 op24); // 此时立刻播放影片,窗口会被随后的场景重建拆掉(实测:影片确实被打开,但没显示)。 // 等重建稳定后再触发即可正常播放。 func triggerClientCutsceneFileDelayed(sequence int) error { if sequence <= 0 || sequence > 99 { return fmt.Errorf("invalid cutscene sequence %d", sequence) } go func(value int) { time.Sleep(2 * time.Second) _ = triggerClientCutsceneFile(value) }(sequence) return nil } // ── 场景就绪后触发 ───────────────────────────────────────────────────── // // 开区任务完成的那一刻客户端正在重建城镇场景;立刻播放影片会被随后的重建拆掉。 // 经验证,可靠的就绪信号是「客户端又发来任意一个包」—— 说明它已经处理完场景 // 重建并在正常交互。因此这里记下"待播影片",等下一个入站包到达时触发。 // // 但玩家可能停在任务结算/对话界面里一直不操作(实测有 7 秒没有发出任何包的 // 情况),只等入站包会让动画迟迟不出现。所以再加一个 cutsceneArmTimeout 兜底: // 到达上限后无论有没有收到包都触发。两条路径共用同一个待播表,先到先得、不会重播。 type cutsceneArmEntry struct { sequence int notBefore time.Time } var cutsceneArmed = struct { sync.Mutex m map[*gameSession]cutsceneArmEntry }{m: make(map[*gameSession]cutsceneArmEntry)} // cutsceneArmTimeout 是"最多等多久"的兜底上限(从 arm 起算)。 const cutsceneArmTimeout = 2 * time.Second // armCutsceneAfterSceneReady 记下待播影片:客户端下一个包到达时触发, // 最多等 cutsceneArmTimeout。 func (s *Service) armCutsceneAfterSceneReady(session *gameSession, sequence int) { if session == nil || sequence <= 0 || sequence > 99 { return } cutsceneArmed.Lock() cutsceneArmed.m[session] = cutsceneArmEntry{ sequence: sequence, notBefore: time.Now().Add(1500 * time.Millisecond), } cutsceneArmed.Unlock() // 兜底定时器:玩家一直不操作时也要把动画播出来。 go func() { time.Sleep(cutsceneArmTimeout) s.fireReadyCutscene(session, "scene_ready_timeout") }() } // takeReadyCutscene 在入站包到达时调用:满足时间条件就取走并返回待播影片号。 func takeReadyCutscene(session *gameSession) (int, bool) { if session == nil { return 0, false } now := time.Now() cutsceneArmed.Lock() entry, ok := cutsceneArmed.m[session] if ok && now.After(entry.notBefore) { delete(cutsceneArmed.m, session) } else { ok = false } cutsceneArmed.Unlock() return entry.sequence, ok } // fireReadyCutscene 取走待播影片并写触发文件,然后把握高的 last_level 拉回当前 // 等级(否则下次进副本会被判定为段差而补播)。入站包路径和超时兜底共用本函数; // 待播表是"取走即删除",所以两条路径不会重复播放。 func (s *Service) fireReadyCutscene(session *gameSession, source string) { sequence, ready := takeReadyCutscene(session) if !ready { return } if err := triggerClientCutsceneFile(sequence); err != nil { s.logGameEvent(session, "game-upper-finish-quest-cutscene-file-trigger-failed", "sequence", sequence, "source", source, "error", err) return } s.logGameEvent(session, "game-upper-finish-quest-cutscene-file-triggered", "sequence", sequence, "source", source) // 开区路径为了触发影片故意让 last_level 落后一段;场景已经就绪后 // 必须把它拉回当前等级,否则下一次进副本又会被判定为段差并补播。 s.syncCurrentStoryDigestToCurrentLevel(session, "cutscene_after_scene_ready") } // forceTownSceneReentry 强制客户端完整重建城镇场景(方案 B)。 // // 注意:2026-10-20 起本函数**不再被调用**。按需求去掉了「章节过场前的假场景切换」, // 章节影片改由客户端补丁的内置播放器播放(见 armCutsceneAfterSceneReady / // triggerClientCutsceneFile)。保留实现仅作回滚用:若要恢复"靠完整场景加载触发 // 原生播放",在 quest_finish_town_scene_refresh.go 的 townAreaOpened 分支里重新 // 加回 2 秒延迟调用即可。 // // 已确认:客户端只在**完整场景加载**时自己播放章节过场(这也是小退重登能播的原因); // 同区域 op24 的"局部重建"不会播。因此这里用「先转到同城镇的另一个区域、再转回原 // 区域」制造一次真正的场景切换 —— 客户端重建场景时会自己把该区域影片播出来。 // // 只发 op24(场景转移),不改持久坐标、不动存档;玩家会看到一次很短的读图。 func (s *Service) forceTownSceneReentry(session *gameSession, townID, areaID byte, snapshot currentTownPositionSnapshot, areaState int64) error { if s == nil || session == nil || session.selectedCharacterID == 0 { return nil } selfRow := currentSceneTransitionRow{ ObjectOrResourceKey: currentSceneActorObjectKey(session.selectedCharacterID), Value1: snapshot.PositionX, Value2: snapshot.PositionY, Value3: snapshot.MovementCode, Value4: byte(areaState), } // 中间区域:同城镇里换一个区域号(0/1 之间切换),再转回原区域。 intermediate := byte(0) if areaID == 0 { intermediate = 1 } for index, target := range []byte{intermediate, areaID} { body, err := buildCurrentSceneTransitionBody(townID, target, []currentSceneTransitionRow{selfRow}) if err != nil { return err } if err := s.sendGameUpperRawClass(session, currentSceneTransitionMsgID, body, 0); err != nil { return err } s.logGameEvent(session, "game-upper-finish-quest-cutscene-scene-reentry", "step", index+1, "town_id", townID, "area_id", target) time.Sleep(500 * time.Millisecond) } return nil }