From ffbe61a28e23dcffccd261f4b55aad6e6641975b Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 15:37:31 +0900 Subject: [PATCH] Add story beats to opening battle --- data/scenarios/001_yellow_turbans.json | 60 +++++++++++++++- scripts/core/battle_state.gd | 5 +- scripts/scenes/battle_scene.gd | 3 + tools/smoke_chapter_one_polish.gd | 96 ++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 4 deletions(-) diff --git a/data/scenarios/001_yellow_turbans.json b/data/scenarios/001_yellow_turbans.json index a0f61fb..2284a55 100644 --- a/data/scenarios/001_yellow_turbans.json +++ b/data/scenarios/001_yellow_turbans.json @@ -252,7 +252,8 @@ { "type": "dialogue", "lines": [ - { "speaker": "Cao Cao", "display_speaker": "조조", "side": "left", "text": "적은 흩어져 있으나, 머뭇거리면 그 수가 불어날 것이다." }, + { "speaker": "Cao Cao", "display_speaker": "조조", "side": "left", "text": "황건의 깃발은 마을 뒤 성채에 기대어 서 있다. 들판의 잔병을 먼저 끊고 성채로 길을 열어라." }, + { "speaker": "Zhang Mancheng", "display_speaker": "장만성", "side": "right", "text": "관군이 둘뿐이라니, 우습구나. 성채에 닿기 전에 들판에서 지치게 만들어라!" }, { "speaker": "Xiahou Dun", "display_speaker": "하후돈", "side": "right", "text": "그렇다면 지금 치겠습니다. 제가 앞줄을 열어 길을 내겠습니다." } ] } @@ -264,7 +265,14 @@ "when": { "type": "unit_reaches_tile", "team": "player", "pos": [3, 2] }, "actions": [ { "type": "log", "text": "북쪽 숲 보급고를 거두었다." }, - { "type": "grant_item", "item_id": "bean" } + { "type": "grant_item", "item_id": "bean" }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Scout", "display_speaker": "척후", "side": "right", "text": "숲 속 낡은 수레에서 콩 자루를 찾았습니다. 황건병이 미처 옮기지 못한 물자입니다." }, + { "speaker": "Cao Cao", "display_speaker": "조조", "side": "left", "text": "좋다. 작은 보급도 오래 버티는 군에는 큰 힘이 된다. 전열에 나누어라." } + ] + } ] }, { @@ -272,7 +280,14 @@ "once": true, "when": { "type": "turn_start", "team": "enemy", "turn": 2 }, "actions": [ - { "type": "log", "text": "황건적이 마을과 성채에 기대어 전열을 고친다." } + { "type": "log", "text": "황건적이 마을과 성채에 기대어 전열을 고친다." }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Scout", "display_speaker": "척후", "side": "right", "text": "마을 앞 수레가 움직입니다. 다친 적병이 집 뒤로 물러나 숨을 고릅니다." }, + { "speaker": "Cao Cao", "display_speaker": "조조", "side": "left", "text": "회복할 곳을 그냥 두면 싸움이 길어진다. 길목과 마을을 함께 눌러라." } + ] + } ] }, { @@ -281,6 +296,13 @@ "when": { "type": "turn_start", "team": "enemy", "turn": 4 }, "actions": [ { "type": "log", "text": "동쪽 들판에서 황건 잔병이 합류했다." }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Xiahou Dun", "display_speaker": "하후돈", "side": "right", "text": "동쪽 먼지가 일어납니다. 흩어진 잔병이 제 선봉 쪽으로 몰립니다." }, + { "speaker": "Cao Cao", "display_speaker": "조조", "side": "left", "text": "좋다. 네가 버티는 동안 중앙의 깃발을 끊겠다. 너무 깊이 끌려가지는 마라." } + ] + }, { "type": "spawn_deployments", "deployments": [ @@ -318,6 +340,13 @@ "when": { "type": "turn_start", "team": "enemy", "turn": 7 }, "actions": [ { "type": "log", "text": "남쪽 샛길에서 약탈대가 뒤늦게 모습을 드러냈다." }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Scout", "display_speaker": "척후", "side": "right", "text": "남쪽 샛길입니다! 약탈대가 본진 쪽 깃발을 보고 돌아듭니다." }, + { "speaker": "Cao Cao", "display_speaker": "조조", "side": "left", "text": "뒤를 물어 시간을 벌려는 수작이다. 본진을 세워 두고 성채 압박을 늦추지 마라." } + ] + }, { "type": "spawn_deployments", "deployments": [ @@ -355,6 +384,17 @@ "when": { "type": "turn_start", "team": "enemy", "turn": 9 }, "actions": [ { "type": "log", "text": "서쪽 숲길에서 마지막 황건대가 전장에 닿았다." }, + { + "type": "dialogue", + "lines": [ + { "speaker": "Zhang Mancheng", "display_speaker": "장만성", "side": "right", "text": "마지막 무리까지 불러들였다. 성채가 버티는 한 관군은 이 들판을 지나지 못한다!" }, + { "speaker": "Cao Cao", "display_speaker": "조조", "side": "left", "text": "저들의 의지와 도망길이 한곳에 모였다. 성채를 붙들고 남은 깃발을 모두 꺾어라." } + ] + }, + { + "type": "set_objective", + "victory": "성채를 장악한 채 마지막 황건대까지 모두 격파하라." + }, { "type": "spawn_deployments", "deployments": [ @@ -394,6 +434,20 @@ "text": "서쪽 황건대가 조조의 깃발을 노린다." } ] + }, + { + "id": "zhang_mancheng_falls", + "once": true, + "when": { "type": "unit_defeated", "team": "enemy", "unit_ids": ["yellow_turban_1"] }, + "actions": [ + { + "type": "dialogue", + "lines": [ + { "speaker": "Zhang Mancheng", "display_speaker": "장만성", "side": "right", "text": "성채의 깃발이... 이토록 빨리 꺾일 줄이야. 흩어져라, 산 자는 숲으로 빠져라!" }, + { "speaker": "Cao Cao", "display_speaker": "조조", "side": "left", "text": "두령이 무너졌어도 잔병은 아직 들판에 남았다. 성채를 붙들고 남은 깃발을 거두어라." } + ] + } + ] } ], "rewards": { diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index cafd286..85a1957 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -4338,11 +4338,14 @@ func _normalize_dialogue_line(line) -> Dictionary: var portrait := str(line.get("portrait", "")) if portrait.is_empty(): portrait = data_catalog.get_portrait_for_speaker(speaker) + var side := "" + if line.has("side"): + side = _normalize_dialogue_side(line.get("side", "")) return { "speaker": display_speaker, "text": text, "portrait": portrait, - "side": _normalize_dialogue_side(line.get("side", "left")) + "side": side } return {} diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 5fdb9d6..d18be48 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -125,6 +125,7 @@ const SPEAKER_DISPLAY_NAMES := { "Cao Ang": "조앙", "Xu Rong": "서영", "Li Jue": "이각", + "Zhang Mancheng": "장만성", "Chen Gong": "진궁", "Gao Shun": "고순", "Lu Bu": "여포", @@ -173,6 +174,7 @@ const SPEAKER_CANONICAL_NAMES := { "조앙": "Cao Ang", "서영": "Xu Rong", "이각": "Li Jue", + "장만성": "Zhang Mancheng", "진궁": "Chen Gong", "고순": "Gao Shun", "여포": "Lu Bu", @@ -211,6 +213,7 @@ const DIALOGUE_RIGHT_SIDE_SPEAKERS := { "Yuan Scout": true, "Imperial Envoy": true, "Imperial Guard": true, + "Zhang Mancheng": true, "Xu Rong": true, "Li Jue": true, "Zhang Xiu": true, diff --git a/tools/smoke_chapter_one_polish.gd b/tools/smoke_chapter_one_polish.gd index 58bce99..af174a4 100644 --- a/tools/smoke_chapter_one_polish.gd +++ b/tools/smoke_chapter_one_polish.gd @@ -1,5 +1,6 @@ extends SceneTree +const BattleStateScript := preload("res://scripts/core/battle_state.gd") const BattleSceneScript := preload("res://scripts/scenes/battle_scene.gd") const CHAPTER_ONE_SCENARIOS := [ @@ -21,6 +22,9 @@ func _init() -> void: _check_edge_scroll_layout_contract(failures) _check_readability_contract(failures) _check_dialogue_localization_and_side(failures) + _check_event_dialogue_side_passthrough(failures) + _check_opening_battle_event_dialogue_structure(failures) + _check_opening_battle_story_beats(failures) _check_pixel_unit_helpers(failures) if failures.is_empty(): @@ -122,6 +126,98 @@ func _check_dialogue_localization_and_side(failures: Array[String]) -> void: scene.free() +func _check_event_dialogue_side_passthrough(failures: Array[String]) -> void: + var state = BattleStateScript.new() + var scene = BattleSceneScript.new() + var raw_lines := state._dialogue_lines_from_action({ + "type": "dialogue", + "lines": [ + {"speaker": "Zhang Mancheng", "text": "성채를 지켜라."}, + {"speaker": "Cao Cao", "text": "길을 열어라."} + ] + }) + if raw_lines.size() != 2: + failures.append("battle events should preserve valid dialogue lines") + scene.free() + return + if str((raw_lines[0] as Dictionary).get("side", "")) != "": + failures.append("battle state should not force event dialogue without an explicit side to the left") + var normalized := scene._normalized_dialogue_lines(raw_lines) + if normalized.size() != 2: + failures.append("scene should normalize battle event dialogue lines") + elif normalized[0].get("speaker", "") != "장만성" or normalized[0].get("side", "") != "right": + failures.append("event dialogue should localize Zhang Mancheng and infer right-side layout") + scene.free() + + +func _check_opening_battle_event_dialogue_structure(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load opening battle for event dialogue structure") + return + for event_id in [ + "opening_dialogue", + "northern_woods_cache", + "turn_2_warning", + "turn_4_eastern_reserve", + "turn_7_southern_raiders", + "turn_9_western_reserve", + "zhang_mancheng_falls" + ]: + var event := _event_by_id(state, event_id) + if event.is_empty(): + failures.append("opening battle missing event: %s" % event_id) + continue + if not _event_has_dialogue_action(event): + failures.append("opening battle event should include contextual dialogue: %s" % event_id) + var boss_event := _event_by_id(state, "zhang_mancheng_falls") + if not boss_event.is_empty(): + var when: Dictionary = boss_event.get("when", {}) + var unit_ids: Array = when.get("unit_ids", []) + if not unit_ids.has("yellow_turban_1"): + failures.append("Zhang Mancheng defeat event should watch yellow_turban_1") + + +func _check_opening_battle_story_beats(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load opening battle for story beat checks") + return + + var dialogue_batches := [] + state.dialogue_requested.connect(func(lines: Array) -> void: + dialogue_batches.append(lines) + ) + + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 2) + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 4) + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 7) + state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) + + if dialogue_batches.size() < 4: + failures.append("opening battle should add dialogue beats for each major reinforcement phase") + var victory_text := str(state.objectives.get("victory", "")) + if not victory_text.contains("마지막 황건대"): + failures.append("opening battle turn 9 event should update the victory objective after the final reserve arrives") + if not state._condition_gate_open({"after_event": "turn_9_western_reserve"}): + failures.append("opening battle final objective gate should open after the turn 9 story beat") + + +func _event_by_id(state, event_id: String) -> Dictionary: + for event in state.battle_events: + if typeof(event) == TYPE_DICTIONARY and str(event.get("id", "")) == event_id: + return event + return {} + + +func _event_has_dialogue_action(event: Dictionary) -> bool: + var actions: Array = event.get("actions", []) + for action in actions: + if typeof(action) == TYPE_DICTIONARY and str(action.get("type", "")) == "dialogue": + return true + return false + + func _check_pixel_unit_helpers(failures: Array[String]) -> void: var scene = BattleSceneScript.new() for method_name in [