From 0fed16fe32392ca2cb7699196ef67f8525e184b0 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 19 Jun 2026 18:21:06 +0900 Subject: [PATCH] Sequence enemy turn presentation --- scripts/scenes/battle_scene.gd | 156 ++++++++++++++++++++++++++---- tools/smoke_chapter_one_polish.gd | 66 +++++++++++++ 2 files changed, 204 insertions(+), 18 deletions(-) diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 384926f..82733b8 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -56,6 +56,8 @@ const MINIMAP_PADDING := 8.0 const MINIMAP_TITLE_HEIGHT := 18.0 const MINIMAP_MARGIN := Vector2(18, 18) const MAP_FOCUS_MARGIN := 96.0 +const BATTLE_PRESENTATION_STEP_GAP := 0.08 +const BATTLE_PRESENTATION_FEEDBACK_LAG := 0.10 const UNIT_MOVE_ANIMATION_DURATION := 0.18 const TARGET_PREVIEW_BADGE_SIZE := Vector2(104, 22) const LOW_HP_WARNING_RATIO := 0.35 @@ -431,6 +433,9 @@ var floating_texts: Array[Dictionary] = [] var objective_notice_timer := 0.0 var unit_motion_by_unit: Dictionary = {} var unit_action_motion_by_unit: Dictionary = {} +var battle_presentation_sequence_active := false +var battle_presentation_delay_cursor := 0.0 +var battle_presentation_feedback_delay_by_unit := {} var battle_background_path := "" var battle_background_texture: Texture2D var ui_regular_font: Font @@ -2628,6 +2633,7 @@ func _handle_key(event: InputEventKey) -> void: func _process(delta: float) -> void: var needs_redraw := false + var had_sequence_presentation := _has_active_battle_presentation_sequence() if battle_started and state.battle_status == BattleState.STATUS_ACTIVE and not _is_dialogue_visible(): needs_redraw = true if _update_edge_scroll(delta): @@ -2642,6 +2648,7 @@ func _process(delta: float) -> void: for popup in floating_texts: var next_popup := popup.duplicate(true) next_popup["age"] = float(next_popup.get("age", 0.0)) + delta + _activate_presentation_focus_if_ready(next_popup) if float(next_popup["age"]) < float(next_popup.get("duration", FLOATING_TEXT_LIFETIME)): active_texts.append(next_popup) floating_texts = active_texts @@ -2653,6 +2660,7 @@ func _process(delta: float) -> void: var motion: Dictionary = unit_motion_by_unit[unit_id] var next_motion := motion.duplicate(true) next_motion["age"] = float(next_motion.get("age", 0.0)) + delta + _activate_presentation_focus_if_ready(next_motion) if float(next_motion["age"]) < float(next_motion.get("duration", UNIT_MOVE_ANIMATION_DURATION)): active_motion[unit_id] = next_motion unit_motion_by_unit = active_motion @@ -2664,6 +2672,7 @@ func _process(delta: float) -> void: var motion: Dictionary = unit_action_motion_by_unit[unit_id] var next_motion := motion.duplicate(true) next_motion["age"] = float(next_motion.get("age", 0.0)) + delta + _activate_presentation_focus_if_ready(next_motion) if float(next_motion["age"]) < float(next_motion.get("duration", UNIT_ATTACK_ANIMATION_DURATION)): active_actions[unit_id] = next_motion unit_action_motion_by_unit = active_actions @@ -2671,6 +2680,10 @@ func _process(delta: float) -> void: if needs_redraw: queue_redraw() + if had_sequence_presentation and not _has_active_battle_presentation_sequence(): + if status_label != null: + _update_hud() + queue_redraw() func _can_select(unit: Dictionary) -> bool: @@ -3586,12 +3599,13 @@ func _unit_pixel_facing(unit: Dictionary) -> int: var unit_id := str(unit.get("id", "")) if not unit_id.is_empty() and unit_action_motion_by_unit.has(unit_id): var action_motion: Dictionary = unit_action_motion_by_unit[unit_id] - var from_cell: Vector2i = action_motion.get("from", unit.get("pos", Vector2i.ZERO)) - var to_cell: Vector2i = action_motion.get("to", from_cell) - if to_cell.x < from_cell.x: - return -1 - if to_cell.x > from_cell.x: - return 1 + if float(action_motion.get("age", 0.0)) >= 0.0: + var from_cell: Vector2i = action_motion.get("from", unit.get("pos", Vector2i.ZERO)) + var to_cell: Vector2i = action_motion.get("to", from_cell) + if to_cell.x < from_cell.x: + return -1 + if to_cell.x > from_cell.x: + return 1 if unit.has("facing_x"): return state.unit_facing_x(unit) if unit.get("team", "") == BattleState.TEAM_ENEMY: @@ -4098,6 +4112,8 @@ func _draw_attack_effects() -> void: var motion: Dictionary = unit_action_motion_by_unit[unit_id] var duration: float = maxf(0.01, float(motion.get("duration", UNIT_ATTACK_ANIMATION_DURATION))) var age := float(motion.get("age", 0.0)) + if age < 0.0: + continue var progress := clampf(age / duration, 0.0, 1.0) var profile := str(motion.get("profile", "slash")) var peak := _action_motion_effect_peak(profile, progress) @@ -4311,6 +4327,8 @@ func _draw_floating_texts() -> void: for popup in floating_texts: var duration: float = maxf(0.01, float(popup.get("duration", FLOATING_TEXT_LIFETIME))) var age := float(popup.get("age", 0.0)) + if age < 0.0: + continue var progress := clampf(age / duration, 0.0, 1.0) var alpha := 1.0 - progress var color := _floating_text_color(str(popup.get("kind", ""))) @@ -4844,6 +4862,67 @@ func _focus_board_on_cell(cell: Vector2i, force_center := false) -> bool: return true +func _activate_presentation_focus_if_ready(entry: Dictionary) -> void: + if bool(entry.get("focused", false)): + return + if float(entry.get("age", 0.0)) < 0.0: + return + var focus_cell: Vector2i = entry.get("focus_cell", Vector2i(-1, -1)) + if state.is_inside(focus_cell): + _focus_board_on_cell(focus_cell, bool(entry.get("force_focus", false))) + var sfx_key := str(entry.get("sfx", "")) + if not sfx_key.is_empty() and not bool(entry.get("sfx_played", false)) and is_inside_tree(): + _play_sfx(sfx_key) + entry["sfx_played"] = true + entry["focused"] = true + + +func _begin_battle_presentation_sequence() -> void: + battle_presentation_sequence_active = true + battle_presentation_delay_cursor = 0.0 + battle_presentation_feedback_delay_by_unit.clear() + + +func _end_battle_presentation_sequence() -> void: + battle_presentation_sequence_active = false + battle_presentation_delay_cursor = 0.0 + + +func _presentation_delay_for_step(duration: float) -> float: + if not battle_presentation_sequence_active: + return 0.0 + var delay := battle_presentation_delay_cursor + battle_presentation_delay_cursor += maxf(0.01, duration) + BATTLE_PRESENTATION_STEP_GAP + return delay + + +func _presentation_feedback_delay_for_unit(unit_id: String) -> float: + if battle_presentation_feedback_delay_by_unit.has(unit_id): + return maxf(0.0, float(battle_presentation_feedback_delay_by_unit[unit_id])) + if battle_presentation_sequence_active: + return maxf(0.0, battle_presentation_delay_cursor) + return 0.0 + + +func _clear_presentation_state() -> void: + battle_presentation_sequence_active = false + battle_presentation_delay_cursor = 0.0 + battle_presentation_feedback_delay_by_unit.clear() + + +func _has_active_battle_presentation_sequence() -> bool: + for motion in unit_motion_by_unit.values(): + if typeof(motion) == TYPE_DICTIONARY and bool((motion as Dictionary).get("sequence", false)): + return true + for motion in unit_action_motion_by_unit.values(): + if typeof(motion) == TYPE_DICTIONARY and bool((motion as Dictionary).get("sequence", false)): + return true + for popup in floating_texts: + if typeof(popup) == TYPE_DICTIONARY and bool((popup as Dictionary).get("sequence", false)): + return true + return false + + func _is_minimap_visible() -> bool: return battle_started and state.map_size.x > 0 and state.map_size.y > 0 and state.battle_status == BattleState.STATUS_ACTIVE @@ -5788,14 +5867,20 @@ func _on_combat_feedback_requested(unit_id: String, text: String, kind: String) var cell: Vector2i = unit.get("pos", Vector2i(-1, -1)) if not state.is_inside(cell): return - _focus_board_on_cell(cell, true) + var delay := _presentation_feedback_delay_for_unit(unit_id) + if delay <= 0.0: + _focus_board_on_cell(cell, true) var jitter := float((floating_texts.size() % 3) - 1) * 12.0 floating_texts.append({ "text": text, "kind": kind, "pos": _floating_text_origin(cell) + Vector2(jitter, 0), - "age": 0.0, - "duration": FLOATING_TEXT_LIFETIME + "age": -delay, + "duration": FLOATING_TEXT_LIFETIME, + "focus_cell": cell, + "force_focus": true, + "focused": delay <= 0.0, + "sequence": delay > 0.0 }) queue_redraw() @@ -5803,12 +5888,20 @@ func _on_combat_feedback_requested(unit_id: String, text: String, kind: String) func _on_unit_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i) -> void: if unit_id.is_empty() or from_cell == to_cell: return - _focus_board_on_cell(to_cell) + var duration := UNIT_MOVE_ANIMATION_DURATION + var delay := _presentation_delay_for_step(duration) + battle_presentation_feedback_delay_by_unit[unit_id] = delay + duration + BATTLE_PRESENTATION_FEEDBACK_LAG + if delay <= 0.0: + _focus_board_on_cell(to_cell) unit_motion_by_unit[unit_id] = { "from": from_cell, "to": to_cell, - "age": 0.0, - "duration": UNIT_MOVE_ANIMATION_DURATION + "age": -delay, + "duration": duration, + "focus_cell": to_cell, + "force_focus": false, + "focused": delay <= 0.0, + "sequence": battle_presentation_sequence_active or delay > 0.0 } queue_redraw() @@ -5821,7 +5914,12 @@ func _on_unit_action_motion_requested(unit_id: String, from_cell: Vector2i, to_c if from_cell == to_cell and not _action_motion_allows_same_cell(profile): return var focus_cell := to_cell if state.is_inside(to_cell) else from_cell - _focus_board_on_cell(focus_cell, true) + var duration := _action_motion_duration(profile) + var delay := _presentation_delay_for_step(duration) + battle_presentation_feedback_delay_by_unit[unit_id] = delay + duration * 0.58 + if delay <= 0.0: + _focus_board_on_cell(focus_cell, true) + var sfx_key := _action_motion_sfx(profile) unit_action_motion_by_unit[unit_id] = { "from": from_cell, "to": to_cell, @@ -5832,11 +5930,17 @@ func _on_unit_action_motion_requested(unit_id: String, from_cell: Vector2i, to_c "vfx_signature": _action_motion_vfx_signature(profile), "impact_cue": _action_motion_impact_cue(profile), "impact_radius": _action_motion_impact_radius(profile), - "age": 0.0, - "duration": _action_motion_duration(profile) + "age": -delay, + "duration": duration, + "focus_cell": focus_cell, + "force_focus": true, + "focused": delay <= 0.0, + "sfx": sfx_key, + "sfx_played": delay <= 0.0, + "sequence": battle_presentation_sequence_active or delay > 0.0 } - if is_inside_tree(): - _play_sfx(_action_motion_sfx(profile)) + if delay <= 0.0 and is_inside_tree(): + _play_sfx(sfx_key) queue_redraw() @@ -6313,7 +6417,9 @@ func _on_end_turn_pressed() -> void: _hide_item_menu() _hide_equip_menu() _hide_post_move_picker() + _begin_battle_presentation_sequence() state.end_player_turn() + _end_battle_presentation_sequence() func _on_tactic_pressed() -> void: @@ -6428,6 +6534,8 @@ func _on_restart_pressed() -> void: post_battle_dialogue_started = false floating_texts.clear() unit_motion_by_unit.clear() + unit_action_motion_by_unit.clear() + _clear_presentation_state() shop_sell_mode = false selected_skill_id = "" selected_item_id = "" @@ -6474,6 +6582,8 @@ func _load_scenario(scenario_id: String) -> void: post_battle_dialogue_started = false floating_texts.clear() unit_motion_by_unit.clear() + unit_action_motion_by_unit.clear() + _clear_presentation_state() selected_skill_id = "" selected_item_id = "" _clear_pending_move_state() @@ -7525,6 +7635,8 @@ func _reload_from_campaign_state() -> void: post_battle_dialogue_started = false floating_texts.clear() unit_motion_by_unit.clear() + unit_action_motion_by_unit.clear() + _clear_presentation_state() selected_skill_id = "" selected_item_id = "" _clear_pending_move_state() @@ -8278,7 +8390,13 @@ func _sale_price_for_item(item: Dictionary) -> int: func _is_input_locked() -> bool: - return campaign_complete_screen or _is_dialogue_visible() or not battle_started or state.battle_status != BattleState.STATUS_ACTIVE + return ( + campaign_complete_screen + or _is_dialogue_visible() + or _has_active_battle_presentation_sequence() + or not battle_started + or state.battle_status != BattleState.STATUS_ACTIVE + ) func _apply_battle_result_once() -> void: @@ -8760,6 +8878,8 @@ func _show_campaign_complete() -> void: post_battle_dialogue_started = true floating_texts.clear() unit_motion_by_unit.clear() + unit_action_motion_by_unit.clear() + _clear_presentation_state() move_cells.clear() attack_cells.clear() skill_cells.clear() diff --git a/tools/smoke_chapter_one_polish.gd b/tools/smoke_chapter_one_polish.gd index fdd71c0..51c9e0f 100644 --- a/tools/smoke_chapter_one_polish.gd +++ b/tools/smoke_chapter_one_polish.gd @@ -22,6 +22,7 @@ func _init() -> void: _check_edge_scroll_layout_contract(failures) _check_minimap_navigation_contract(failures) _check_map_focus_contract(failures) + _check_battle_presentation_sequence_contract(failures) _check_readability_contract(failures) _check_dialogue_localization_and_side(failures) _check_event_dialogue_side_passthrough(failures) @@ -290,6 +291,71 @@ func _check_map_focus_contract(failures: Array[String]) -> void: scene.free() +func _check_battle_presentation_sequence_contract(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 map for battle presentation sequencing") + scene.free() + return + scene.battle_started = true + scene.state.battle_status = BattleStateScript.STATUS_ACTIVE + scene.state.current_team = BattleStateScript.TEAM_PLAYER + + for method_name in [ + "_begin_battle_presentation_sequence", + "_end_battle_presentation_sequence", + "_presentation_delay_for_step", + "_has_active_battle_presentation_sequence" + ]: + if not scene.has_method(method_name): + failures.append("missing battle presentation helper: %s" % method_name) + + scene._begin_battle_presentation_sequence() + scene._on_unit_motion_requested("yellow_turban_1", Vector2i(12, 1), Vector2i(11, 1)) + scene._on_unit_action_motion_requested("yellow_turban_2", Vector2i(9, 4), Vector2i(8, 4), "attack") + scene._on_combat_feedback_requested("yellow_turban_2", "-10", "damage") + scene._end_battle_presentation_sequence() + + var first_motion: Dictionary = scene.unit_motion_by_unit.get("yellow_turban_1", {}) + if first_motion.is_empty() or float(first_motion.get("age", 0.0)) < -0.01: + failures.append("first queued enemy movement should start immediately: %s" % str(first_motion)) + if not bool(first_motion.get("sequence", false)): + failures.append("enemy movement should be marked as part of the presentation sequence") + + var action_motion: Dictionary = scene.unit_action_motion_by_unit.get("yellow_turban_2", {}) + if action_motion.is_empty() or float(action_motion.get("age", 0.0)) >= 0.0: + failures.append("later enemy action should wait for earlier movement: %s" % str(action_motion)) + if not bool(action_motion.get("sequence", false)): + failures.append("enemy action should be marked as part of the presentation sequence") + if bool(action_motion.get("sfx_played", false)): + failures.append("delayed enemy action SFX should wait for the action start: %s" % str(action_motion)) + var enemy_before_facing: Dictionary = scene.state.get_unit("yellow_turban_2") + if scene._unit_pixel_facing(enemy_before_facing) != scene.state.unit_facing_x(enemy_before_facing): + failures.append("delayed enemy action should not flip unit facing before it starts") + + if scene.floating_texts.is_empty(): + failures.append("enemy damage feedback should be queued as floating text") + else: + var popup: Dictionary = scene.floating_texts[0] + if float(popup.get("age", 0.0)) >= 0.0: + failures.append("enemy damage feedback should wait for the action impact: %s" % str(popup)) + if not bool(popup.get("sequence", false)): + failures.append("enemy damage feedback should keep input locked while it is presented") + + if not scene._has_active_battle_presentation_sequence(): + failures.append("queued enemy presentation should stay active after enemy turn logic returns") + if not scene._is_input_locked(): + failures.append("queued enemy presentation should lock input until the sequence has played") + + for index in range(40): + scene._process(0.20) + if not scene._has_active_battle_presentation_sequence(): + break + if scene._has_active_battle_presentation_sequence(): + failures.append("queued enemy presentation should finish after enough process time") + scene.free() + + func _check_readability_contract(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._create_hud()