diff --git a/README.md b/README.md index 562a9c9..9ca6459 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Pre-battle shop rows emphasize item artwork, compact price/status badges, and hover details instead of dense inline text. - Pre-battle Talk can list scenario camp conversations by officer or topic with portrait rows, compact topic/supply badges, hover dialogue previews, saved-branch filtering, one-time pre-battle supplies, and merchant flavor lines before a battle. - The opening battle's camp and battle-opening dialogue now include a villager route report that frames the castle, village recovery point, lure line, and northern supply cache before combat starts. +- The opening battle's lure line, village supply, northern cache, and castle objective markers now carry tactical Korean hint text through battlefield hover badges, minimap hover badges, and cell-detail tooltips. - The opening battle now uses a dedicated high-resolution Yingchuan battlefield backdrop, clearer generated terrain-cell texture overlays, image-first recovery/supply/tactic tile markers, no text class boxes on units, quieter objective progress text, and a pulled-back enemy line so the player can lure and form up before the first clash. - The opening victory dialogue now explicitly shows Cao Ren and Xiahou Yuan joining Cao Cao's camp before bridging into the Sishui Gate march against Hua Xiong. - Sishui Gate now opens with camp dialogue that turns the coalition briefing into a spoken march order. diff --git a/data/scenarios/001_yellow_turbans.json b/data/scenarios/001_yellow_turbans.json index c4b2a78..143e4e3 100644 --- a/data/scenarios/001_yellow_turbans.json +++ b/data/scenarios/001_yellow_turbans.json @@ -17,6 +17,7 @@ { "type": "unit_reaches_tile", "label": "성채 장악", + "objective_hint": "조조나 하후돈으로 동쪽 성채에 올라 깃발을 붙들어야 한다.", "team": "player", "unit_ids": [ "cao_cao", @@ -765,7 +766,8 @@ ] ], "show_marker": true, - "marker_kind": "tactic" + "marker_kind": "tactic", + "marker_hint": "마을 앞 유인선까지만 나아가면 황건 앞줄 일부를 끌어낼 수 있다." }, "actions": [ { @@ -814,6 +816,7 @@ "label": "북숲 보급고", "show_marker": true, "marker_kind": "supply", + "marker_hint": "북쪽 숲에 숨은 콩 보급을 거두어 긴 전투에 대비한다.", "pos": [ 4, 2 @@ -856,6 +859,7 @@ "label": "마을 보급", "show_marker": true, "marker_kind": "supply", + "marker_hint": "마을 치료소와 창고를 확보해 회복 거점과 보급을 함께 얻는다.", "cells": [ [ 6, diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index 77a7004..be1d2fb 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -1803,6 +1803,12 @@ func get_objective_cell_label(cell: Vector2i) -> String: return _objective_cell_label_for_condition(battle_conditions.get("victory", {}), cell) +func get_objective_cell_hint(cell: Vector2i) -> String: + if not is_inside(cell): + return "" + return _objective_cell_hint_for_condition(battle_conditions.get("victory", {}), cell) + + func get_event_markers() -> Array[Dictionary]: var result: Array[Dictionary] = [] var objective_cells := get_objective_cells() @@ -1827,6 +1833,7 @@ func get_event_markers() -> Array[Dictionary]: "id": event_id, "label": _event_marker_label(event), "kind": _event_marker_kind(event), + "hint": _event_marker_hint(event), "cells": cells }) return result @@ -1864,6 +1871,16 @@ func get_event_marker_kind(cell: Vector2i) -> String: return "" +func get_event_marker_hint(cell: Vector2i) -> String: + if not is_inside(cell): + return "" + for marker in get_event_markers(): + var cells = marker.get("cells", []) + if typeof(cells) == TYPE_ARRAY and cells.has(cell): + return str(marker.get("hint", "")) + return "" + + func get_objective_progress_lines(include_defeat_risks := false, include_turn_limit := true) -> Array[String]: var result: Array[String] = [] _append_condition_progress_lines(battle_conditions.get("victory", {}), result, "victory") @@ -4418,6 +4435,28 @@ func _objective_cell_label_for_condition(condition_group, cell: Vector2i) -> Str return "" +func _objective_cell_hint_for_condition(condition_group, cell: Vector2i) -> String: + if typeof(condition_group) == TYPE_ARRAY: + for condition in condition_group: + var hint := _objective_cell_hint_for_condition(condition, cell) + if not hint.is_empty(): + return hint + return "" + if typeof(condition_group) != TYPE_DICTIONARY: + return "" + if not _condition_gate_open(condition_group): + return _objective_cell_hint_for_after_event(str(condition_group.get("after_event", "")), cell) + + var condition_type := str(condition_group.get("type", "")) + if condition_type == "all" or condition_type == "any": + return _objective_cell_hint_for_condition(condition_group.get("conditions", []), cell) + if condition_type != "unit_reaches_tile": + return "" + if _condition_cells(condition_group).has(cell): + return _condition_marker_hint(condition_group) + return "" + + func _objective_cell_label_for_after_event(event_id: String, cell: Vector2i) -> String: if event_id.is_empty() or fired_event_ids.has(event_id): return "" @@ -4434,6 +4473,25 @@ func _objective_cell_label_for_after_event(event_id: String, cell: Vector2i) -> return "" +func _objective_cell_hint_for_after_event(event_id: String, cell: Vector2i) -> String: + if event_id.is_empty() or fired_event_ids.has(event_id): + return "" + var event := _event_by_id(event_id) + if event.is_empty(): + return "" + var when: Dictionary = event.get("when", {}) + if str(when.get("type", "")) != "unit_reaches_tile": + return "" + if not _event_gate_open(when) or not _campaign_flags_match(when.get("campaign_flags", {})): + return "" + if _condition_cells(when).has(cell): + var hint := _condition_marker_hint(when) + if hint.is_empty(): + hint = _event_marker_hint(event) + return hint + return "" + + func _event_has_reward_action(event: Dictionary) -> bool: for action in event.get("actions", []): if typeof(action) != TYPE_DICTIONARY: @@ -4476,6 +4534,33 @@ func _event_marker_label(event: Dictionary) -> String: return "전장 표식" +func _condition_marker_hint(condition: Dictionary) -> String: + for key in ["objective_hint", "marker_hint", "hint"]: + var value := str(condition.get(key, "")).strip_edges() + if not value.is_empty(): + return value + return "" + + +func _event_marker_hint(event: Dictionary) -> String: + for key in ["marker_hint", "hint"]: + var value := str(event.get(key, "")).strip_edges() + if not value.is_empty(): + return value + var when: Dictionary = event.get("when", {}) + var condition_hint := _condition_marker_hint(when) + if not condition_hint.is_empty(): + return condition_hint + var kind := _event_marker_kind(event) + if kind == "tactic": + return "적 앞줄만 끌어내기 좋은 전술 표식입니다." + if kind == "gold": + return "전장 군자금을 거둘 수 있는 표식입니다." + if kind == "supply": + return "소모품이나 회복 거점을 얻을 수 있는 보급 표식입니다." + return "" + + func _event_marker_kind(event: Dictionary) -> String: var explicit_kind := str(event.get("marker_kind", "")).strip_edges().to_lower() if explicit_kind.is_empty(): diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 16dfc66..013458e 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -8480,7 +8480,9 @@ func _hover_info_badge_lines(cell: Vector2i) -> Array[String]: var unit: Dictionary = summary.get("unit", {}) var terrain_name := _terrain_display_name(str(summary.get("terrain", ""))) var objective_label := state.get_objective_cell_label(cell) + var objective_hint := state.get_objective_cell_hint(cell) var event_label := state.get_event_marker_label(cell) + var event_hint := state.get_event_marker_hint(cell) var objective_used_as_title := false var event_used_as_title := false if not unit.is_empty(): @@ -8504,9 +8506,13 @@ func _hover_info_badge_lines(cell: Vector2i) -> Array[String]: else: if not objective_label.is_empty(): lines.append("목표 %s" % objective_label) + if not objective_hint.is_empty(): + lines.append(_short_preview_text(objective_hint, 34)) objective_used_as_title = true elif not event_label.is_empty(): lines.append("표식 %s" % event_label) + if not event_hint.is_empty(): + lines.append(_short_preview_text(event_hint, 34)) event_used_as_title = true else: lines.append(terrain_name) @@ -8530,6 +8536,13 @@ func _hover_info_badge_lines(cell: Vector2i) -> Array[String]: markers.append("감시 %s" % _compact_name_list(threat_names, 2)) if not markers.is_empty(): lines.append(_join_strings(markers, " · ")) + var hint_line := "" + if not objective_hint.is_empty() and not objective_used_as_title: + hint_line = objective_hint + elif not event_hint.is_empty() and not event_used_as_title: + hint_line = event_hint + if not hint_line.is_empty(): + lines.append(_short_preview_text(hint_line, 38)) var capped_lines: Array[String] = [] for index in range(mini(lines.size(), 4)): capped_lines.append(lines[index]) @@ -9288,7 +9301,9 @@ func _minimap_hover_badge_lines(cell: Vector2i) -> Array[String]: var lines: Array[String] = [] var unit: Dictionary = summary.get("unit", {}) var objective_label := state.get_objective_cell_label(cell) + var objective_hint := state.get_objective_cell_hint(cell) var event_label := state.get_event_marker_label(cell) + var event_hint := state.get_event_marker_hint(cell) var objective_used_as_title := false var event_used_as_title := false if not unit.is_empty(): @@ -9297,9 +9312,13 @@ func _minimap_hover_badge_lines(cell: Vector2i) -> Array[String]: else: if not objective_label.is_empty(): lines.append("목표 %s" % objective_label) + if not objective_hint.is_empty(): + lines.append(_short_preview_text(objective_hint, 32)) objective_used_as_title = true elif not event_label.is_empty(): lines.append("표식 %s" % event_label) + if not event_hint.is_empty(): + lines.append(_short_preview_text(event_hint, 32)) event_used_as_title = true else: lines.append("전장도 %s" % terrain_name) @@ -9314,6 +9333,13 @@ func _minimap_hover_badge_lines(cell: Vector2i) -> Array[String]: markers.append("감시 %s" % _compact_name_list(threat_names, 2)) if not markers.is_empty(): lines.append(_join_strings(markers, " · ")) + var hint_line := "" + if not objective_hint.is_empty() and not objective_used_as_title: + hint_line = objective_hint + elif not event_hint.is_empty() and not event_used_as_title: + hint_line = event_hint + if not hint_line.is_empty(): + lines.append(_short_preview_text(hint_line, 34)) lines.append("클릭/드래그로 전장 이동") var capped_lines: Array[String] = [] for index in range(mini(lines.size(), 4)): @@ -10393,9 +10419,15 @@ func _format_cell_info_detail_text(cell: Vector2i) -> String: var objective_cell_label := state.get_objective_cell_label(cell) if not objective_cell_label.is_empty(): text += "\n목표: %s" % objective_cell_label + var objective_hint := state.get_objective_cell_hint(cell) + if not objective_hint.is_empty(): + text += "\n%s" % objective_hint var event_marker_label := state.get_event_marker_label(cell) if not event_marker_label.is_empty(): text += "\n표식: %s" % event_marker_label + var event_hint := state.get_event_marker_hint(cell) + if not event_hint.is_empty(): + text += "\n%s" % event_hint if show_threat_overlay: var threat_names := state.get_threatening_unit_names(cell, BattleState.TEAM_ENEMY) if not threat_names.is_empty(): diff --git a/tools/smoke_chapter_one_polish.gd b/tools/smoke_chapter_one_polish.gd index ee9a7db..a2612e6 100644 --- a/tools/smoke_chapter_one_polish.gd +++ b/tools/smoke_chapter_one_polish.gd @@ -31,6 +31,7 @@ func _init() -> void: _check_opening_battle_camp_context(failures) _check_opening_battle_tactical_spacing(failures) _check_opening_battle_log_location_labels(failures) + _check_opening_battle_marker_hints(failures) _check_opening_battle_lure_line_ai_reaction(failures) _check_opening_battle_post_battle_bridge(failures) _check_sishui_gate_camp_context(failures) @@ -837,6 +838,51 @@ func _check_opening_battle_log_location_labels(failures: Array[String]) -> void: failures.append("battle movement logs should name battlefield zones: %s" % log_text) +func _check_opening_battle_marker_hints(failures: Array[String]) -> void: + var scene = BattleSceneScript.new() + if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load opening battle for marker hints") + scene.free() + return + + var castle_cell := Vector2i(20, 0) + var castle_hint: String = scene.state.get_objective_cell_hint(castle_cell) + if not castle_hint.contains("조조") or not castle_hint.contains("성채"): + failures.append("castle objective should explain who must hold the gate: %s" % castle_hint) + var castle_hover_text := "\n".join(scene._hover_info_badge_lines(castle_cell)) + if not castle_hover_text.contains("목표 성채 장악") or not castle_hover_text.contains("깃발"): + failures.append("castle hover should expose objective hint text: %s" % castle_hover_text) + var castle_minimap_text := "\n".join(scene._minimap_hover_badge_lines(castle_cell)) + if not castle_minimap_text.contains("목표 성채 장악") or not castle_minimap_text.contains("동쪽 성채"): + failures.append("castle minimap hover should expose objective hint text: %s" % castle_minimap_text) + var castle_cell_detail := scene._format_cell_info_detail_text(castle_cell) + if not castle_cell_detail.contains("목표: 성채 장악") or not castle_cell_detail.contains("조조나 하후돈"): + failures.append("cell info should carry castle objective detail: %s" % castle_cell_detail) + + var lure_cell := Vector2i(4, 6) + if scene.state.get_event_marker_kind(lure_cell) != "tactic": + failures.append("lure line should keep a tactic marker kind") + var lure_hint: String = scene.state.get_event_marker_hint(lure_cell) + if not lure_hint.contains("유인선") or not lure_hint.contains("끌어낼"): + failures.append("lure line should explain the tactical trigger: %s" % lure_hint) + var lure_hover_text := "\n".join(scene._hover_info_badge_lines(lure_cell)) + if not lure_hover_text.contains("표식 유인선") or not lure_hover_text.contains("끌어낼"): + failures.append("lure hover should expose tactical marker hint: %s" % lure_hover_text) + + var woods_cell := Vector2i(4, 2) + var woods_hint: String = scene.state.get_event_marker_hint(woods_cell) + if not woods_hint.contains("콩") or not woods_hint.contains("보급"): + failures.append("northern cache should explain its supply value: %s" % woods_hint) + var village_cell := Vector2i(6, 5) + var village_hint: String = scene.state.get_event_marker_hint(village_cell) + if not village_hint.contains("치료소") or not village_hint.contains("회복"): + failures.append("village supply should explain recovery and supply value: %s" % village_hint) + var village_detail := scene._format_cell_info_detail_text(village_cell) + if not village_detail.contains("마을 치료소") or not village_detail.contains("회복 거점"): + failures.append("village cell info should explain the recovery/supply marker: %s" % village_detail) + scene.free() + + func _check_opening_battle_lure_line_ai_reaction(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"):