From fb04cbda44161978ebb428dc2b5edf68bb8975dd Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 15:27:54 +0900 Subject: [PATCH] Polish chapter one battle presentation --- scripts/scenes/battle_scene.gd | 320 ++++++++++++++++++++++++++---- tools/smoke_chapter_one_polish.gd | 137 +++++++++++++ 2 files changed, 418 insertions(+), 39 deletions(-) create mode 100644 tools/smoke_chapter_one_polish.gd diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index fc7370d..5fdb9d6 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -47,6 +47,8 @@ const UI_SFX_VOLUME_DB := -8.0 const FLOATING_TEXT_LIFETIME := 1.0 const FLOATING_TEXT_RISE := 42.0 const OBJECTIVE_NOTICE_DURATION := 2.6 +const EDGE_SCROLL_MARGIN := 34.0 +const EDGE_SCROLL_SPEED := 360.0 const UNIT_MOVE_ANIMATION_DURATION := 0.18 const TARGET_PREVIEW_BADGE_SIZE := Vector2(104, 22) const LOW_HP_WARNING_RATIO := 0.35 @@ -62,11 +64,11 @@ const UNIT_ARROW_ATTACK_DURATION := 0.36 const UNIT_COMMAND_ATTACK_DURATION := 0.38 const UNIT_HEAVY_ATTACK_DURATION := 0.34 const UNIT_TACTIC_CAST_DURATION := 0.42 -const DIALOGUE_PANEL_POSITION := Vector2(40, 356) -const DIALOGUE_PANEL_SIZE := Vector2(1200, 304) -const DIALOGUE_PORTRAIT_SIZE := Vector2(220, 262) -const DIALOGUE_COLUMN_SIZE := Vector2(850, 266) -const DIALOGUE_TEXT_SIZE := Vector2(830, 112) +const DIALOGUE_PANEL_POSITION := Vector2(40, 324) +const DIALOGUE_PANEL_SIZE := Vector2(1200, 356) +const DIALOGUE_PORTRAIT_SIZE := Vector2(220, 310) +const DIALOGUE_COLUMN_SIZE := Vector2(850, 318) +const DIALOGUE_TEXT_SIZE := Vector2(830, 164) const BRIEFING_CAMP_THUMBNAIL_SIZE := Vector2(188, 86) const CAMP_TALK_PORTRAIT_SIZE := Vector2(58, 58) const SHOP_MERCHANT_BADGE_SIZE := Vector2(50, 50) @@ -121,9 +123,12 @@ const SPEAKER_DISPLAY_NAMES := { "Zhang Xiu": "장수", "Jia Xu": "가후", "Cao Ang": "조앙", + "Xu Rong": "서영", + "Li Jue": "이각", "Chen Gong": "진궁", "Gao Shun": "고순", "Lu Bu": "여포", + "Qingzhou Chief": "청주 두령", "Yan Liang": "안량", "Wen Chou": "문추", "Yuan Shao": "원소", @@ -166,9 +171,12 @@ const SPEAKER_CANONICAL_NAMES := { "장수": "Zhang Xiu", "가후": "Jia Xu", "조앙": "Cao Ang", + "서영": "Xu Rong", + "이각": "Li Jue", "진궁": "Chen Gong", "고순": "Gao Shun", "여포": "Lu Bu", + "청주 두령": "Qingzhou Chief", "안량": "Yan Liang", "문추": "Wen Chou", "원소": "Yuan Shao", @@ -188,6 +196,46 @@ const SPEAKER_CANONICAL_NAMES := { "천자 금위": "Imperial Guard", "완성 척후": "Wan Scout" } +const DIALOGUE_RIGHT_SIDE_SPEAKERS := { + "Camp Merchant": true, + "Coalition Merchant": true, + "Coalition Quartermaster": true, + "Dingtao Field Sutler": true, + "Escort Road Sutler": true, + "Puyang Night Trader": true, + "Wan Castle Sutler": true, + "Xiapi Flood-Gate Sutler": true, + "White Horse Road Sutler": true, + "Scout": true, + "Wan Scout": true, + "Yuan Scout": true, + "Imperial Envoy": true, + "Imperial Guard": true, + "Xu Rong": true, + "Li Jue": true, + "Zhang Xiu": true, + "Jia Xu": true, + "Zhang Liao": true, + "Chen Gong": true, + "Gao Shun": true, + "Lu Bu": true, + "Qingzhou Chief": true, + "Yuan Shao": true, + "Yan Liang": true, + "Wen Chou": true, + "Ju Shou": true, + "Chunyu Qiong": true, + "Yuan Guard": true, + "Yuan Field Advisor": true, + "Yuan Rally Advisor": true, + "Yuan Tan Guard": true, + "Yuan Shang Guard": true, + "Ye Guard": true, + "Yuan Shang Inner Guard": true, + "Inner City Advisor": true, + "Ye Last Loyalist": true, + "Palace Advisor": true +} var state: BattleState = BattleStateScript.new() var campaign_state: CampaignState = CampaignStateScript.new() @@ -352,6 +400,7 @@ var battle_background_path := "" var battle_background_texture: Texture2D var ui_regular_font: Font var ui_bold_font: Font +var board_scroll_offset := Vector2.ZERO func _ready() -> void: @@ -1522,7 +1571,7 @@ func _create_hud() -> void: briefing_objective_header.add_child(briefing_objective_toggle_button) briefing_objective_panel = PanelContainer.new() - briefing_objective_panel.custom_minimum_size = Vector2(680, 104) + briefing_objective_panel.custom_minimum_size = Vector2(680, 124) _apply_panel_style(briefing_objective_panel, "edict") briefing_column.add_child(briefing_objective_panel) @@ -1530,17 +1579,17 @@ func _create_hud() -> void: briefing_objective_row.add_theme_constant_override("separation", 8) briefing_objective_panel.add_child(briefing_objective_row) - briefing_objective_row.add_child(_make_bamboo_gutter(18, 84)) - briefing_objective_row.add_child(_make_edict_marker_stack(["", "", ""], 84)) + briefing_objective_row.add_child(_make_bamboo_gutter(18, 104)) + briefing_objective_row.add_child(_make_edict_marker_stack(["", "", ""], 104)) briefing_objective_label = Label.new() briefing_objective_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - briefing_objective_label.custom_minimum_size = Vector2(500, 84) + briefing_objective_label.custom_minimum_size = Vector2(500, 104) briefing_objective_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _apply_label_style(briefing_objective_label, UI_AGED_INK, 15) briefing_objective_row.add_child(briefing_objective_label) - briefing_objective_row.add_child(_make_edict_marker_stack(["", "", ""], 84)) - briefing_objective_row.add_child(_make_bamboo_gutter(18, 84)) + briefing_objective_row.add_child(_make_edict_marker_stack(["", "", ""], 104)) + briefing_objective_row.add_child(_make_bamboo_gutter(18, 104)) briefing_column.add_child(_make_ink_wash_rule(680, 8)) briefing_column.add_child(_make_section_caption("전장도와 군막 보고", 680)) @@ -1879,7 +1928,7 @@ func _create_hud() -> void: dialogue_speaker_row.add_child(_make_seal_tile("", 24)) dialogue_text_panel = PanelContainer.new() - dialogue_text_panel.custom_minimum_size = Vector2(DIALOGUE_TEXT_SIZE.x, 104) + dialogue_text_panel.custom_minimum_size = Vector2(DIALOGUE_TEXT_SIZE.x, DIALOGUE_TEXT_SIZE.y) _apply_panel_style(dialogue_text_panel, "speech_scroll") dialogue_column.add_child(dialogue_text_panel) @@ -2426,6 +2475,10 @@ func _handle_key(event: InputEventKey) -> void: func _process(delta: float) -> void: var needs_redraw := false + if battle_started and state.battle_status == BattleState.STATUS_ACTIVE and not _is_dialogue_visible(): + needs_redraw = true + if _update_edge_scroll(delta): + needs_redraw = true if objective_notice_timer > 0.0: objective_notice_timer = maxf(0.0, objective_notice_timer - delta) if objective_notice_timer <= 0.0 and objective_notice_panel != null: @@ -2478,7 +2531,7 @@ func _can_select(unit: Dictionary) -> bool: func _draw_map() -> void: - var board_rect := Rect2(BOARD_OFFSET, Vector2(state.map_size.x, state.map_size.y) * TILE_SIZE) + var board_rect := _board_rect() var background := _current_battle_background_texture() var has_background := background != null if background != null: @@ -2976,7 +3029,6 @@ func _draw_overlays() -> void: func _draw_units() -> void: - var font := _draw_ui_font() for unit in state.units: if not unit.get("alive", false) or not unit.get("deployed", true): continue @@ -2984,25 +3036,11 @@ func _draw_units() -> void: var rect := _visual_rect_for_unit(unit) var center := rect.position + rect.size * 0.5 var team_color := PLAYER_COLOR if unit.get("team", "") == BattleState.TEAM_PLAYER else ENEMY_COLOR - var token_back := Color(0.02, 0.022, 0.026, 0.78) var acted := bool(unit.get("acted", false)) var sprite_modulate := _unit_sprite_modulate(unit, acted) - draw_circle(center + Vector2(0, 3), 29, Color(0.0, 0.0, 0.0, 0.38)) - draw_circle(center, 28, token_back) - var sprite_texture := _load_art_texture(str(unit.get("sprite", ""))) - if sprite_texture != null: - var sprite_target := Rect2(rect.position + Vector2(5, 1), Vector2(TILE_SIZE - 10, TILE_SIZE - 16)) - var sprite_rect := _fit_texture_rect(sprite_texture, sprite_target) - draw_texture_rect(sprite_texture, sprite_rect, false, sprite_modulate) - else: - var body_color := team_color.darkened(0.35) if acted else team_color - draw_circle(center, 22, body_color) - draw_arc(center, 28, 0.0, TAU, 64, Color(0.03, 0.035, 0.04), 3.0) - draw_arc(center, 28, -PI * 0.65, PI * 0.35, 36, team_color.lightened(0.18), 3.0) - _draw_unit_identity_marks(rect, center, unit, team_color) - _draw_unit_class_emblem(rect, center, unit, team_color) - _draw_unit_class_badge(rect, unit, team_color) + draw_circle(center + Vector2(0, 17), 20, Color(0.0, 0.0, 0.0, 0.30)) + _draw_pixel_unit_sprite(rect, unit, team_color, sprite_modulate) var hp_ratio := float(unit["hp"]) / float(max(1, int(unit["max_hp"]))) var hp_back := Rect2(rect.position + Vector2(8, TILE_SIZE - 8), Vector2(TILE_SIZE - 16, 5)) @@ -3154,6 +3192,124 @@ func _unit_sprite_modulate(unit: Dictionary, acted: bool) -> Color: return Color.WHITE +func _draw_pixel_unit_sprite(rect: Rect2, unit: Dictionary, team_color: Color, modulate: Color) -> void: + var family := _unit_class_family(unit) + var phase := _unit_idle_phase(unit) + var bob := roundf(sin(phase) * 1.25) + var step := 1.0 if sin(phase * 1.65) >= 0.0 else -1.0 + var pixel := 3.0 + var origin := Vector2(roundf(rect.position.x + 12.0), roundf(rect.position.y + 5.0 + bob)) + var outline := _unit_pixel_color(Color(0.035, 0.026, 0.018, 1.0), modulate) + var skin := _unit_pixel_color(Color(0.86, 0.62, 0.38, 1.0), modulate) + var cloth := _unit_pixel_color(team_color.lightened(0.05), modulate) + var cloth_dark := _unit_pixel_color(team_color.darkened(0.34), modulate) + var class_color := _unit_pixel_color(_unit_class_badge_color(unit, team_color), modulate) + var accent := _unit_pixel_color(Color(0.98, 0.76, 0.28, 1.0) if not str(unit.get("officer_id", "")).is_empty() else class_color.lightened(0.25), modulate) + + _draw_pixel_rect(origin, 2, 11, 8, 2, pixel, Color(0.0, 0.0, 0.0, 0.22 * modulate.a)) + if family == "cavalry": + _draw_pixel_cavalry(origin, pixel, outline, skin, cloth, cloth_dark, class_color, accent, step) + elif family == "ranged": + _draw_pixel_archer(origin, pixel, outline, skin, cloth, cloth_dark, class_color, accent, step) + elif family == "tactic": + _draw_pixel_strategist(origin, pixel, outline, skin, cloth, cloth_dark, class_color, accent, step) + elif family == "heavy": + _draw_pixel_heavy(origin, pixel, outline, skin, cloth, cloth_dark, class_color, accent, step) + else: + _draw_pixel_infantry(origin, pixel, outline, skin, cloth, cloth_dark, class_color, accent, step) + + +func _unit_idle_phase(unit: Dictionary) -> float: + var seed := 0 + var unit_id := str(unit.get("id", "unit")) + for index in range(unit_id.length()): + seed += unit_id.unicode_at(index) * (index + 1) + return float(Time.get_ticks_msec()) * 0.0024 + float(seed % 360) * 0.01745 + + +func _unit_pixel_color(color: Color, modulate: Color) -> Color: + return Color( + color.r * modulate.r, + color.g * modulate.g, + color.b * modulate.b, + color.a * modulate.a + ) + + +func _draw_pixel_rect(origin: Vector2, x: float, y: float, width: float, height: float, pixel: float, color: Color) -> void: + draw_rect(Rect2(origin + Vector2(x, y) * pixel, Vector2(width, height) * pixel), color) + + +func _draw_pixel_infantry(origin: Vector2, pixel: float, outline: Color, skin: Color, cloth: Color, cloth_dark: Color, class_color: Color, accent: Color, step: float) -> void: + _draw_pixel_rect(origin, 4, 1, 4, 2, pixel, outline) + _draw_pixel_rect(origin, 5, 2, 2, 2, pixel, skin) + _draw_pixel_rect(origin, 3, 4, 6, 5, pixel, outline) + _draw_pixel_rect(origin, 4, 4, 4, 4, pixel, cloth) + _draw_pixel_rect(origin, 3, 6, 2, 3, pixel, class_color) + _draw_pixel_rect(origin, 7, 6, 2, 3, pixel, cloth_dark) + _draw_pixel_rect(origin, 4 + step, 9, 2, 3, pixel, outline) + _draw_pixel_rect(origin, 7 - step, 9, 2, 3, pixel, outline) + _draw_pixel_rect(origin, 1, 4, 1, 8, pixel, accent.lightened(0.15)) + _draw_pixel_rect(origin, 0, 3, 3, 1, pixel, accent) + + +func _draw_pixel_archer(origin: Vector2, pixel: float, outline: Color, skin: Color, cloth: Color, cloth_dark: Color, class_color: Color, accent: Color, step: float) -> void: + _draw_pixel_rect(origin, 4, 1, 4, 2, pixel, outline) + _draw_pixel_rect(origin, 5, 2, 2, 2, pixel, skin) + _draw_pixel_rect(origin, 3, 4, 6, 5, pixel, outline) + _draw_pixel_rect(origin, 4, 4, 4, 4, pixel, cloth) + _draw_pixel_rect(origin, 5, 5, 2, 4, pixel, class_color) + _draw_pixel_rect(origin, 4 + step, 9, 2, 3, pixel, outline) + _draw_pixel_rect(origin, 7 - step, 9, 2, 3, pixel, outline) + draw_line(origin + Vector2(9, 3) * pixel, origin + Vector2(11, 7) * pixel, accent, 2.0) + draw_line(origin + Vector2(11, 7) * pixel, origin + Vector2(9, 11) * pixel, accent, 2.0) + draw_line(origin + Vector2(4, 6) * pixel, origin + Vector2(11, 7) * pixel, accent.lightened(0.15), 1.3) + _draw_pixel_rect(origin, 2, 5, 2, 1, pixel, cloth_dark) + + +func _draw_pixel_cavalry(origin: Vector2, pixel: float, outline: Color, skin: Color, cloth: Color, cloth_dark: Color, class_color: Color, accent: Color, step: float) -> void: + var horse := _unit_pixel_color(Color(0.33, 0.20, 0.11, 1.0), Color(1.0, 1.0, 1.0, outline.a)) + var mane := _unit_pixel_color(Color(0.12, 0.075, 0.04, 1.0), Color(1.0, 1.0, 1.0, outline.a)) + _draw_pixel_rect(origin, 1, 7, 9, 3, pixel, outline) + _draw_pixel_rect(origin, 2, 7, 7, 2, pixel, horse) + _draw_pixel_rect(origin, 8, 5, 3, 3, pixel, outline) + _draw_pixel_rect(origin, 8, 6, 2, 2, pixel, horse) + _draw_pixel_rect(origin, 7, 5, 1, 3, pixel, mane) + _draw_pixel_rect(origin, 2 + step, 10, 2, 3, pixel, outline) + _draw_pixel_rect(origin, 7 - step, 10, 2, 3, pixel, outline) + _draw_pixel_rect(origin, 4, 2, 4, 2, pixel, outline) + _draw_pixel_rect(origin, 5, 3, 2, 2, pixel, skin) + _draw_pixel_rect(origin, 4, 5, 4, 3, pixel, cloth) + _draw_pixel_rect(origin, 5, 5, 2, 2, pixel, class_color) + draw_line(origin + Vector2(4, 4) * pixel, origin + Vector2(2, 1) * pixel, accent, 2.0) + + +func _draw_pixel_strategist(origin: Vector2, pixel: float, outline: Color, skin: Color, cloth: Color, cloth_dark: Color, class_color: Color, accent: Color, step: float) -> void: + _draw_pixel_rect(origin, 4, 1, 4, 2, pixel, outline) + _draw_pixel_rect(origin, 5, 2, 2, 2, pixel, skin) + _draw_pixel_rect(origin, 3, 4, 6, 7, pixel, outline) + _draw_pixel_rect(origin, 4, 4, 4, 6, pixel, cloth) + _draw_pixel_rect(origin, 3, 6, 2, 3, pixel, class_color) + _draw_pixel_rect(origin, 7, 6, 2, 3, pixel, class_color) + _draw_pixel_rect(origin, 4 + step, 11, 2, 2, pixel, outline) + _draw_pixel_rect(origin, 7 - step, 11, 2, 2, pixel, outline) + _draw_pixel_rect(origin, 8, 3, 3, 1, pixel, accent) + _draw_pixel_rect(origin, 9, 4, 2, 1, pixel, accent) + _draw_pixel_rect(origin, 2, 5, 1, 5, pixel, cloth_dark) + + +func _draw_pixel_heavy(origin: Vector2, pixel: float, outline: Color, skin: Color, cloth: Color, cloth_dark: Color, class_color: Color, accent: Color, step: float) -> void: + _draw_pixel_rect(origin, 4, 1, 4, 2, pixel, outline) + _draw_pixel_rect(origin, 5, 2, 2, 2, pixel, skin) + _draw_pixel_rect(origin, 2, 4, 8, 6, pixel, outline) + _draw_pixel_rect(origin, 3, 4, 6, 5, pixel, cloth) + _draw_pixel_rect(origin, 4, 5, 4, 4, pixel, class_color) + _draw_pixel_rect(origin, 3 + step, 10, 2, 3, pixel, outline) + _draw_pixel_rect(origin, 8 - step, 10, 2, 3, pixel, outline) + draw_line(origin + Vector2(9, 2) * pixel, origin + Vector2(2, 10) * pixel, accent, 3.0) + _draw_pixel_rect(origin, 8, 1, 3, 2, pixel, accent.lightened(0.20)) + + func _draw_unit_identity_marks(rect: Rect2, center: Vector2, unit: Dictionary, team_color: Color) -> void: if _is_generic_enemy_unit(unit): var sash := ENEMY_COLOR.lightened(0.22) @@ -3960,18 +4116,86 @@ func _target_preview_badge_color(kind: String) -> Color: func _cell_from_screen(screen_position: Vector2) -> Vector2i: - var local := screen_position - BOARD_OFFSET + var local := screen_position - _board_origin() if local.x < 0 or local.y < 0: return Vector2i(-1, -1) return Vector2i(int(floor(local.x / TILE_SIZE)), int(floor(local.y / TILE_SIZE))) func _rect_for_cell(cell: Vector2i) -> Rect2: - return Rect2(BOARD_OFFSET + Vector2(cell.x, cell.y) * TILE_SIZE, Vector2(TILE_SIZE, TILE_SIZE)) + return Rect2(_board_origin() + Vector2(cell.x, cell.y) * TILE_SIZE, Vector2(TILE_SIZE, TILE_SIZE)) func _board_rect() -> Rect2: - return Rect2(BOARD_OFFSET, Vector2(state.map_size.x, state.map_size.y) * TILE_SIZE) + return Rect2(_board_origin(), _board_size()) + + +func _board_origin() -> Vector2: + return BOARD_OFFSET + board_scroll_offset + + +func _board_size() -> Vector2: + return Vector2(state.map_size.x, state.map_size.y) * TILE_SIZE + + +func _map_view_rect() -> Rect2: + var viewport_size := _viewport_size_for_layout() + var right_edge := minf(SIDE_PANEL_POSITION.x - 18.0, viewport_size.x - 18.0) + var bottom_edge := viewport_size.y - 20.0 + return Rect2( + BOARD_OFFSET, + Vector2(maxf(160.0, right_edge - BOARD_OFFSET.x), maxf(160.0, bottom_edge - BOARD_OFFSET.y)) + ) + + +func _viewport_size_for_layout() -> Vector2: + if is_inside_tree(): + return get_viewport_rect().size + var window_size := Vector2(DisplayServer.window_get_size()) + if window_size.x > 0.0 and window_size.y > 0.0: + return window_size + return Vector2(1280, 720) + + +func _reset_board_scroll() -> void: + board_scroll_offset = _clamped_board_scroll_offset(Vector2.ZERO) + + +func _clamped_board_scroll_offset(offset: Vector2) -> Vector2: + var view_rect := _map_view_rect() + var board_size := _board_size() + var min_x := minf(0.0, view_rect.size.x - board_size.x) + var min_y := minf(0.0, view_rect.size.y - board_size.y) + return Vector2(clampf(offset.x, min_x, 0.0), clampf(offset.y, min_y, 0.0)) + + +func _update_edge_scroll(delta: float) -> bool: + if _is_input_locked(): + return false + var view_rect := _map_view_rect() + var mouse_position := get_viewport().get_mouse_position() + if not view_rect.has_point(mouse_position): + return false + var velocity := Vector2.ZERO + if mouse_position.x <= view_rect.position.x + EDGE_SCROLL_MARGIN: + velocity.x += EDGE_SCROLL_SPEED + elif mouse_position.x >= view_rect.end.x - EDGE_SCROLL_MARGIN: + velocity.x -= EDGE_SCROLL_SPEED + if mouse_position.y <= view_rect.position.y + EDGE_SCROLL_MARGIN: + velocity.y += EDGE_SCROLL_SPEED + elif mouse_position.y >= view_rect.end.y - EDGE_SCROLL_MARGIN: + velocity.y -= EDGE_SCROLL_SPEED + if velocity == Vector2.ZERO: + return false + var previous_offset := board_scroll_offset + board_scroll_offset = _clamped_board_scroll_offset(board_scroll_offset + velocity * delta) + if previous_offset.distance_squared_to(board_scroll_offset) <= 0.01: + return false + var next_hover := _cell_from_screen(mouse_position) + if next_hover != hover_cell: + hover_cell = next_hover + _update_hud() + return true func _short_name(unit_name: String) -> String: @@ -5041,7 +5265,7 @@ func _normalized_dialogue_lines(lines: Array) -> Array: "speaker": display_speaker, "text": text, "portrait": portrait, - "side": _normalized_dialogue_side(line.get("side", "left")) + "side": _dialogue_side_for_line(line) }) elif typeof(line) == TYPE_STRING: var text := str(line) @@ -5058,6 +5282,16 @@ func _normalized_dialogue_side(value) -> String: return "left" +func _dialogue_side_for_line(line: Dictionary) -> String: + var explicit_side := str(line.get("side", "")).strip_edges() + if not explicit_side.is_empty(): + return _normalized_dialogue_side(explicit_side) + var speaker := _canonical_speaker_name(str(line.get("speaker", line.get("display_speaker", "")))) + if DIALOGUE_RIGHT_SIDE_SPEAKERS.has(speaker): + return "right" + return "left" + + func _can_show_dialogue_now() -> bool: if not battle_started or campaign_complete_screen: return false @@ -5095,7 +5329,7 @@ func _render_dialogue_line() -> void: var line: Dictionary = active_dialogue_lines[active_dialogue_index] var speaker := str(line.get("speaker", "")) var display_speaker := _localized_speaker_name(str(line.get("display_speaker", speaker))) - var side := _normalized_dialogue_side(line.get("side", "left")) + var side := _dialogue_side_for_line(line) _apply_dialogue_side(side) dialogue_speaker_label.text = display_speaker if not display_speaker.is_empty() else "" dialogue_speaker_label.visible = not display_speaker.is_empty() @@ -5467,6 +5701,7 @@ func _load_scenario(scenario_id: String) -> void: campaign_state.get_joined_officers_snapshot(), campaign_state.get_scenario_title(active_scenario_id) ) + _reset_board_scroll() func _format_briefing_title(briefing: Dictionary) -> String: @@ -7832,7 +8067,7 @@ func _update_post_move_menu() -> void: func _local_command_panel_position_for_cell(cell: Vector2i, panel_size: Vector2) -> Vector2: if not state.is_inside(cell): - return BOARD_OFFSET + Vector2(LOCAL_COMMAND_PANEL_BOARD_PADDING, LOCAL_COMMAND_PANEL_BOARD_PADDING) + return _board_origin() + Vector2(LOCAL_COMMAND_PANEL_BOARD_PADDING, LOCAL_COMMAND_PANEL_BOARD_PADDING) var cell_rect := _rect_for_cell(cell) var centered_y := cell_rect.position.y + (TILE_SIZE - panel_size.y) * 0.5 var centered_x := cell_rect.position.x + (TILE_SIZE - panel_size.x) * 0.5 @@ -7860,9 +8095,16 @@ func _local_command_panel_position_for_cell(cell: Vector2i, panel_size: Vector2) func _clamped_local_command_panel_position(position: Vector2, panel_size: Vector2) -> Vector2: - var board_size := Vector2(state.map_size.x, state.map_size.y) * TILE_SIZE - var board_min := BOARD_OFFSET + Vector2(LOCAL_COMMAND_PANEL_BOARD_PADDING, LOCAL_COMMAND_PANEL_BOARD_PADDING) - var board_max := BOARD_OFFSET + board_size - panel_size - Vector2(LOCAL_COMMAND_PANEL_BOARD_PADDING, LOCAL_COMMAND_PANEL_BOARD_PADDING) + var board_rect := _board_rect() + var view_rect := _map_view_rect() + var board_min := Vector2( + maxf(board_rect.position.x, view_rect.position.x), + maxf(board_rect.position.y, view_rect.position.y) + ) + Vector2(LOCAL_COMMAND_PANEL_BOARD_PADDING, LOCAL_COMMAND_PANEL_BOARD_PADDING) + var board_max := Vector2( + minf(board_rect.end.x, view_rect.end.x), + minf(board_rect.end.y, view_rect.end.y) + ) - panel_size - Vector2(LOCAL_COMMAND_PANEL_BOARD_PADDING, LOCAL_COMMAND_PANEL_BOARD_PADDING) board_max.x = maxf(board_min.x, board_max.x) board_max.y = maxf(board_min.y, board_max.y) return Vector2( diff --git a/tools/smoke_chapter_one_polish.gd b/tools/smoke_chapter_one_polish.gd new file mode 100644 index 0000000..58bce99 --- /dev/null +++ b/tools/smoke_chapter_one_polish.gd @@ -0,0 +1,137 @@ +extends SceneTree + +const BattleSceneScript := preload("res://scripts/scenes/battle_scene.gd") + +const CHAPTER_ONE_SCENARIOS := [ + "res://data/scenarios/001_yellow_turbans.json", + "res://data/scenarios/002_sishui_gate.json", + "res://data/scenarios/003_xingyang_ambush.json", + "res://data/scenarios/004_qingzhou_campaign.json", + "res://data/scenarios/005_puyang_raid.json", + "res://data/scenarios/006_dingtao_counterattack.json", + "res://data/scenarios/007_xian_emperor_escort.json", + "res://data/scenarios/008_wan_castle_escape.json", + "res://data/scenarios/009_xiapi_siege.json" +] + + +func _init() -> void: + var failures: Array[String] = [] + _check_chapter_one_scenarios_have_visual_grounding(failures) + _check_edge_scroll_layout_contract(failures) + _check_readability_contract(failures) + _check_dialogue_localization_and_side(failures) + _check_pixel_unit_helpers(failures) + + if failures.is_empty(): + print("chapter one polish smoke ok") + quit(0) + return + + for failure in failures: + push_error(failure) + quit(1) + + +func _check_chapter_one_scenarios_have_visual_grounding(failures: Array[String]) -> void: + var scene = BattleSceneScript.new() + for scenario_path in CHAPTER_ONE_SCENARIOS: + if not scene.state.load_battle(scenario_path): + failures.append("could not load chapter one scenario: %s" % scenario_path) + continue + var background_path: String = scene.state.get_map_background_path() + if background_path.is_empty(): + failures.append("scenario is missing a map background: %s" % scenario_path) + continue + if not FileAccess.file_exists(background_path): + failures.append("scenario background does not exist: %s -> %s" % [scenario_path, background_path]) + scene.free() + + +func _check_edge_scroll_layout_contract(failures: Array[String]) -> void: + var scene = BattleSceneScript.new() + if not scene.state.load_battle("res://data/scenarios/009_xiapi_siege.json"): + failures.append("could not load large chapter one map for edge scrolling") + scene.free() + return + + var view_rect := scene._map_view_rect() + var board_size := scene._board_size() + if view_rect.size.x < 160.0 or view_rect.size.y < 160.0: + failures.append("map view rect should keep a usable minimum size") + if board_size.x <= view_rect.size.x and board_size.y <= view_rect.size.y: + failures.append("chapter one should include at least one map large enough to exercise edge scrolling") + + scene._reset_board_scroll() + if scene.board_scroll_offset.x > 0.0 or scene.board_scroll_offset.y > 0.0: + failures.append("reset board scroll should never push the map past the top-left origin") + + var far_offset := scene._clamped_board_scroll_offset(Vector2(-9999.0, -9999.0)) + var min_x := minf(0.0, view_rect.size.x - board_size.x) + var min_y := minf(0.0, view_rect.size.y - board_size.y) + if far_offset.x < min_x - 0.01 or far_offset.y < min_y - 0.01: + failures.append("edge scroll clamp should stay inside the visible map bounds") + if far_offset.x > 0.01 or far_offset.y > 0.01: + failures.append("edge scroll clamp should not create positive board offsets") + + scene.board_scroll_offset = far_offset + var first_cell_screen := scene._rect_for_cell(Vector2i(0, 0)).position + Vector2(4.0, 4.0) + if scene._cell_from_screen(first_cell_screen) != Vector2i(0, 0): + failures.append("screen-to-cell mapping should stay stable after scrolling") + scene.free() + + +func _check_readability_contract(failures: Array[String]) -> void: + var scene = BattleSceneScript.new() + scene._create_hud() + if scene.dialogue_text_panel.custom_minimum_size.y < 160.0: + failures.append("dialogue text panel should be tall enough for multi-line story text") + if scene.dialogue_text_label.custom_minimum_size.y < 130.0: + failures.append("dialogue label should reserve enough height to avoid clipping") + if scene.dialogue_text_label.autowrap_mode != TextServer.AUTOWRAP_WORD_SMART: + failures.append("dialogue text should use smart Korean-friendly wrapping") + if scene.briefing_objective_panel.custom_minimum_size.y < 120.0: + failures.append("briefing objective panel should reserve enough height for victory and defeat text") + if scene.briefing_objective_toggle_button == null: + failures.append("briefing objective section should have a close/open button") + elif scene.briefing_objective_toggle_button.text != "닫기": + failures.append("briefing objective button should start with a clear close label") + scene.free() + + +func _check_dialogue_localization_and_side(failures: Array[String]) -> void: + var scene = BattleSceneScript.new() + var lines := scene._normalized_dialogue_lines([ + {"speaker": "Cao Cao", "text": "전열을 세워라."}, + {"speaker": "Lu Bu", "text": "조조를 짓밟아라."}, + {"speaker": "Qingzhou Chief", "text": "마을 곡식을 내놓아라."}, + {"speaker": "Camp Merchant", "text": "군막에 물자가 남았습니다."} + ]) + if lines.size() != 4: + failures.append("dialogue normalization should keep all valid chapter one lines") + scene.free() + return + if lines[0].get("speaker", "") != "조조" or lines[0].get("side", "") != "left": + failures.append("Cao Cao dialogue should localize to Korean on the left") + if lines[1].get("speaker", "") != "여포" or lines[1].get("side", "") != "right": + failures.append("enemy officer dialogue should localize to Korean on the right") + if lines[2].get("speaker", "") != "청주 두령" or lines[2].get("side", "") != "right": + failures.append("chapter one named enemy dialogue should be localized and right-sided") + if lines[3].get("speaker", "") != "군막 상인" or lines[3].get("side", "") != "right": + failures.append("camp merchant dialogue should be localized and right-sided by default") + scene.free() + + +func _check_pixel_unit_helpers(failures: Array[String]) -> void: + var scene = BattleSceneScript.new() + for method_name in [ + "_draw_pixel_unit_sprite", + "_draw_pixel_infantry", + "_draw_pixel_archer", + "_draw_pixel_cavalry", + "_draw_pixel_strategist", + "_draw_pixel_heavy" + ]: + if not scene.has_method(method_name): + failures.append("missing pixel-map unit helper: %s" % method_name) + scene.free()