Add unit movement slide animation

This commit is contained in:
2026-06-18 03:28:40 +09:00
parent 889b5a3743
commit 05af755da0
5 changed files with 72 additions and 14 deletions

View File

@@ -105,6 +105,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr
- Victory and defeat result overlay. - Victory and defeat result overlay.
- Placeholder menu/battle BGM plus UI, movement, attack, tactic, item, victory, and defeat SFX. - Placeholder menu/battle BGM plus UI, movement, attack, tactic, item, victory, and defeat SFX.
- Floating combat text for damage, recovery, misses, support effects, level-ups, and promotions. - Floating combat text for damage, recovery, misses, support effects, level-ups, and promotions.
- Short unit slide animation for board movement.
- Data-driven scenario setup through JSON definitions and deployments. - Data-driven scenario setup through JSON definitions and deployments.
## Run ## Run

View File

@@ -38,7 +38,7 @@
4. Show scenario briefing, including the optional pre-battle shop, Armory, Roster, and Formation setup. 4. Show scenario briefing, including the optional pre-battle shop, Armory, Roster, and Formation setup.
5. Player selects a unit. 5. Player selects a unit.
6. Briefing completion dispatches battle-begin events, including opening dialogue. 6. Briefing completion dispatches battle-begin events, including opening dialogue.
7. Unit moves, attacks, chooses a tactic or consumable item from the side menu, changes equipment, counters, gains EXP, levels up, promotes at class thresholds, or waits. Tactic buttons show MP cost, kind, range, and power or support effect. Physical attacks roll hit chance from unit agility and target terrain avoid; misses give reduced EXP. The scene plays placeholder SFX and floating combat text for core action and UI feedback. 7. Unit moves, attacks, chooses a tactic or consumable item from the side menu, changes equipment, counters, gains EXP, levels up, promotes at class thresholds, or waits. Tactic buttons show MP cost, kind, range, and power or support effect. Physical attacks roll hit chance from unit agility and target terrain avoid; misses give reduced EXP. The scene plays placeholder SFX, a short movement slide, and floating combat text for core action and UI feedback.
8. Player ends turn. 8. Player ends turn.
9. Enemy AI moves, attacks, and can cast damage or healing tactics through the same combat and skill resolvers. 9. Enemy AI moves, attacks, and can cast damage or healing tactics through the same combat and skill resolvers.
10. Battle checks scenario-defined defeat conditions first, including commander loss and turn limits, then victory conditions such as enemy defeat or reaching a marked destination tile. 10. Battle checks scenario-defined defeat conditions first, including commander loss and turn limits, then victory conditions such as enemy defeat or reaching a marked destination tile.
@@ -58,6 +58,6 @@ Joined officers gate whether `requires_joined` player deployments are loaded at
Inventory and campaign flags are copied from `CampaignState` into `BattleState` when a scenario starts. Pre-battle shop stock comes from the loaded scenario's `shop.items` list plus matching `shop.conditional_items` blocks. Shop purchases and 50% sell-back are campaign transactions: they update saved gold and unequipped inventory immediately, write the save file, and refresh the already-loaded battle inventory before the player begins the battle. Pre-battle Armory equipment changes use the same equipment rules as battle HUD equipment changes, then immediately save merged roster equipment/stat snapshots and inventory stock. Pre-battle Roster uses scenario `roster.max_units`, `roster.required_officers`, and `roster.required_units` to mark optional player deployments as sortie or reserve; reserve units remain in the candidate list but are excluded from board occupancy, drawing, selection, AI targeting, and living-unit victory checks. Non-controllable player units can act as protected escort targets: enemies can attack them and conditions can reference them, but they are skipped by player selection, Armory, Formation, counterattacks, and campaign roster progression snapshots when `persist_progression` is false. Scenario `ai_target_priority` nudges enemy movement and damage-skill scoring toward important targets such as envoys without overriding defeat bonuses or range checks. Pre-battle Formation uses the loaded scenario's `formation.cells` and only mutates current battle unit positions before battle-begin events fire. Destination victory cells from `unit_reaches_tile` conditions are exposed to the scene for objective overlays and tile info. Movement-triggered `unit_reaches_tile` events receive the unit that just moved, so ambushes fire on entry rather than from units already standing on a marker. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, and reinforcements from saved campaign choices. Consumable use can restore HP or MP, and in-battle item use plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items or preserve in-battle gear changes. Post-battle choices write campaign `flags` only after the result-panel button is pressed; if the player reloads after rewards are saved but before selecting, the pending scenario id restores the victory choice panel without replaying rewards. The next battle button stays locked until the campaign choice save succeeds. Equipment rewards share the same `item_id -> count` inventory stock, cover weapon, armor, and accessory slots, can be equipped from the side HUD before the selected unit moves or acts, and do not appear in the consumable action menu. Already completed scenarios can be replayed without granting duplicate rewards, choices, or replay inventory consumption; pre-battle Shop, Armory, Roster, and Formation are disabled on completed-scenario replays to avoid side effects. Inventory and campaign flags are copied from `CampaignState` into `BattleState` when a scenario starts. Pre-battle shop stock comes from the loaded scenario's `shop.items` list plus matching `shop.conditional_items` blocks. Shop purchases and 50% sell-back are campaign transactions: they update saved gold and unequipped inventory immediately, write the save file, and refresh the already-loaded battle inventory before the player begins the battle. Pre-battle Armory equipment changes use the same equipment rules as battle HUD equipment changes, then immediately save merged roster equipment/stat snapshots and inventory stock. Pre-battle Roster uses scenario `roster.max_units`, `roster.required_officers`, and `roster.required_units` to mark optional player deployments as sortie or reserve; reserve units remain in the candidate list but are excluded from board occupancy, drawing, selection, AI targeting, and living-unit victory checks. Non-controllable player units can act as protected escort targets: enemies can attack them and conditions can reference them, but they are skipped by player selection, Armory, Formation, counterattacks, and campaign roster progression snapshots when `persist_progression` is false. Scenario `ai_target_priority` nudges enemy movement and damage-skill scoring toward important targets such as envoys without overriding defeat bonuses or range checks. Pre-battle Formation uses the loaded scenario's `formation.cells` and only mutates current battle unit positions before battle-begin events fire. Destination victory cells from `unit_reaches_tile` conditions are exposed to the scene for objective overlays and tile info. Movement-triggered `unit_reaches_tile` events receive the unit that just moved, so ambushes fire on entry rather than from units already standing on a marker. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, and reinforcements from saved campaign choices. Consumable use can restore HP or MP, and in-battle item use plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items or preserve in-battle gear changes. Post-battle choices write campaign `flags` only after the result-panel button is pressed; if the player reloads after rewards are saved but before selecting, the pending scenario id restores the victory choice panel without replaying rewards. The next battle button stays locked until the campaign choice save succeeds. Equipment rewards share the same `item_id -> count` inventory stock, cover weapon, armor, and accessory slots, can be equipped from the side HUD before the selected unit moves or acts, and do not appear in the consumable action menu. Already completed scenarios can be replayed without granting duplicate rewards, choices, or replay inventory consumption; pre-battle Shop, Armory, Roster, and Formation are disabled on completed-scenario replays to avoid side effects.
The battle scene owns presentation feedback: briefing and result states use menu BGM, active battles use battle BGM, log/result hooks trigger placeholder SFX, and `BattleState.combat_feedback_requested` asks the scene to draw transient floating combat text without changing battle rules. The battle scene owns presentation feedback: briefing and result states use menu BGM, active battles use battle BGM, log/result hooks trigger placeholder SFX, `BattleState.unit_motion_requested` asks the scene to interpolate a unit's draw position during movement, and `BattleState.combat_feedback_requested` asks the scene to draw transient floating combat text without changing battle rules.
The `New Campaign` button clears `user://campaign_save.json`, resets campaign state to the first scenario, and reloads the opening briefing. The `New Campaign` button clears `user://campaign_save.json`, resets campaign state to the first scenario, and reloads the opening briefing.

View File

@@ -131,7 +131,7 @@
## Milestone 5: Presentation ## Milestone 5: Presentation
- Portrait pipeline. - Portrait pipeline.
- Unit sprites and animations. - Unit sprites and animations. Board movement now has a short slide animation.
- Battle effects. First floating combat text now covers damage, recovery, misses, support effects, level-ups, and promotions. - Battle effects. First floating combat text now covers damage, recovery, misses, support effects, level-ups, and promotions.
- Music and sound. Basic placeholder BGM and SFX now play for menus, battle flow, unit actions, tactics, items, and result states. - Music and sound. Basic placeholder BGM and SFX now play for menus, battle flow, unit actions, tactics, items, and result states.
- Chapter UI and visual novel scenes. - Chapter UI and visual novel scenes.

View File

@@ -7,6 +7,7 @@ signal changed
signal log_added(message: String) signal log_added(message: String)
signal dialogue_requested(lines: Array) signal dialogue_requested(lines: Array)
signal combat_feedback_requested(unit_id: String, text: String, kind: String) signal combat_feedback_requested(unit_id: String, text: String, kind: String)
signal unit_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i)
const TEAM_PLAYER := "player" const TEAM_PLAYER := "player"
const TEAM_ENEMY := "enemy" const TEAM_ENEMY := "enemy"
@@ -539,8 +540,10 @@ func try_move_selected(cell: Vector2i) -> bool:
if not get_unit_at(cell).is_empty(): if not get_unit_at(cell).is_empty():
return false return false
var from_cell: Vector2i = unit["pos"]
unit["pos"] = cell unit["pos"] = cell
unit["moved"] = true unit["moved"] = true
unit_motion_requested.emit(str(unit.get("id", "")), from_cell, cell)
_emit_log("%s moved to %s." % [unit["name"], _format_cell(cell)]) _emit_log("%s moved to %s." % [unit["name"], _format_cell(cell)])
_run_events("unit_reaches_tile", str(unit.get("team", "")), turn_number, unit) _run_events("unit_reaches_tile", str(unit.get("team", "")), turn_number, unit)
_check_battle_status() _check_battle_status()
@@ -1837,8 +1840,10 @@ func _enemy_take_action(enemy: Dictionary) -> void:
best_cell = cell best_cell = cell
if best_cell != enemy["pos"]: if best_cell != enemy["pos"]:
var from_cell: Vector2i = enemy["pos"]
enemy["pos"] = best_cell enemy["pos"] = best_cell
enemy["moved"] = true enemy["moved"] = true
unit_motion_requested.emit(str(enemy.get("id", "")), from_cell, best_cell)
_emit_log("%s advances to %s." % [enemy["name"], _format_cell(best_cell)]) _emit_log("%s advances to %s." % [enemy["name"], _format_cell(best_cell)])
_run_events("unit_reaches_tile", str(enemy.get("team", "")), turn_number, enemy) _run_events("unit_reaches_tile", str(enemy.get("team", "")), turn_number, enemy)
if battle_status != STATUS_ACTIVE: if battle_status != STATUS_ACTIVE:

View File

@@ -33,6 +33,7 @@ const SFX_VOLUME_DB := -4.0
const UI_SFX_VOLUME_DB := -8.0 const UI_SFX_VOLUME_DB := -8.0
const FLOATING_TEXT_LIFETIME := 1.0 const FLOATING_TEXT_LIFETIME := 1.0
const FLOATING_TEXT_RISE := 42.0 const FLOATING_TEXT_RISE := 42.0
const UNIT_MOVE_ANIMATION_DURATION := 0.18
var state: BattleState = BattleStateScript.new() var state: BattleState = BattleStateScript.new()
var campaign_state: CampaignState = CampaignStateScript.new() var campaign_state: CampaignState = CampaignStateScript.new()
@@ -110,6 +111,7 @@ var bgm_player: AudioStreamPlayer
var current_bgm_key := "" var current_bgm_key := ""
var last_announced_battle_status := "" var last_announced_battle_status := ""
var floating_texts: Array[Dictionary] = [] var floating_texts: Array[Dictionary] = []
var unit_motion_by_unit: Dictionary = {}
func _ready() -> void: func _ready() -> void:
@@ -119,6 +121,7 @@ func _ready() -> void:
state.log_added.connect(_on_log_added) state.log_added.connect(_on_log_added)
state.dialogue_requested.connect(_on_dialogue_requested) state.dialogue_requested.connect(_on_dialogue_requested)
state.combat_feedback_requested.connect(_on_combat_feedback_requested) state.combat_feedback_requested.connect(_on_combat_feedback_requested)
state.unit_motion_requested.connect(_on_unit_motion_requested)
campaign_state.load_campaign(CAMPAIGN_PATH) campaign_state.load_campaign(CAMPAIGN_PATH)
campaign_state.load_or_start(campaign_state.get_start_scenario_id()) campaign_state.load_or_start(campaign_state.get_start_scenario_id())
if campaign_state.has_pending_post_battle_choice(): if campaign_state.has_pending_post_battle_choice():
@@ -738,16 +741,30 @@ func _handle_key(event: InputEventKey) -> void:
func _process(delta: float) -> void: func _process(delta: float) -> void:
if floating_texts.is_empty(): var needs_redraw := false
return if not floating_texts.is_empty():
var active_texts: Array[Dictionary] = [] var active_texts: Array[Dictionary] = []
for popup in floating_texts: for popup in floating_texts:
var next_popup := popup.duplicate(true) var next_popup := popup.duplicate(true)
next_popup["age"] = float(next_popup.get("age", 0.0)) + delta next_popup["age"] = float(next_popup.get("age", 0.0)) + delta
if float(next_popup["age"]) < float(next_popup.get("duration", FLOATING_TEXT_LIFETIME)): if float(next_popup["age"]) < float(next_popup.get("duration", FLOATING_TEXT_LIFETIME)):
active_texts.append(next_popup) active_texts.append(next_popup)
floating_texts = active_texts floating_texts = active_texts
queue_redraw() needs_redraw = true
if not unit_motion_by_unit.is_empty():
var active_motion := {}
for unit_id in unit_motion_by_unit.keys():
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
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
needs_redraw = true
if needs_redraw:
queue_redraw()
func _can_select(unit: Dictionary) -> bool: func _can_select(unit: Dictionary) -> bool:
@@ -815,7 +832,7 @@ func _draw_units() -> void:
if not unit.get("alive", false) or not unit.get("deployed", true): if not unit.get("alive", false) or not unit.get("deployed", true):
continue continue
var rect := _rect_for_cell(unit["pos"]) var rect := _visual_rect_for_unit(unit)
var center := rect.position + rect.size * 0.5 var center := rect.position + rect.size * 0.5
var team_color := PLAYER_COLOR if unit.get("team", "") == BattleState.TEAM_PLAYER else ENEMY_COLOR var team_color := PLAYER_COLOR if unit.get("team", "") == BattleState.TEAM_PLAYER else ENEMY_COLOR
var body_color := team_color.darkened(0.35) if unit.get("acted", false) else team_color var body_color := team_color.darkened(0.35) if unit.get("acted", false) else team_color
@@ -836,6 +853,26 @@ func _draw_units() -> void:
draw_arc(center, 29, 0.0, TAU, 48, Color(1.0, 0.92, 0.25), 3.0) draw_arc(center, 29, 0.0, TAU, 48, Color(1.0, 0.92, 0.25), 3.0)
func _visual_rect_for_unit(unit: Dictionary) -> Rect2:
var cell: Vector2i = unit.get("pos", Vector2i(-1, -1))
var rect := _rect_for_cell(cell)
var unit_id := str(unit.get("id", ""))
if unit_id.is_empty() or not unit_motion_by_unit.has(unit_id):
return rect
var motion: Dictionary = unit_motion_by_unit[unit_id]
var from_cell: Vector2i = motion.get("from", cell)
var to_cell: Vector2i = motion.get("to", cell)
var duration := max(0.01, float(motion.get("duration", UNIT_MOVE_ANIMATION_DURATION)))
var age := float(motion.get("age", 0.0))
var progress := clampf(age / duration, 0.0, 1.0)
var smooth_progress := progress * progress * (3.0 - 2.0 * progress)
var from_rect := _rect_for_cell(from_cell)
var to_rect := _rect_for_cell(to_cell)
rect.position = from_rect.position.lerp(to_rect.position, smooth_progress)
return rect
func _draw_floating_texts() -> void: func _draw_floating_texts() -> void:
if floating_texts.is_empty(): if floating_texts.is_empty():
return return
@@ -1262,6 +1299,18 @@ func _on_combat_feedback_requested(unit_id: String, text: String, kind: String)
queue_redraw() queue_redraw()
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
unit_motion_by_unit[unit_id] = {
"from": from_cell,
"to": to_cell,
"age": 0.0,
"duration": UNIT_MOVE_ANIMATION_DURATION
}
queue_redraw()
func _floating_text_origin(cell: Vector2i) -> Vector2: func _floating_text_origin(cell: Vector2i) -> Vector2:
var rect := _rect_for_cell(cell) var rect := _rect_for_cell(cell)
return rect.position + Vector2(TILE_SIZE * 0.5, 18.0) return rect.position + Vector2(TILE_SIZE * 0.5, 18.0)
@@ -1447,6 +1496,7 @@ func _on_restart_pressed() -> void:
battle_result_summary.clear() battle_result_summary.clear()
post_battle_dialogue_started = false post_battle_dialogue_started = false
floating_texts.clear() floating_texts.clear()
unit_motion_by_unit.clear()
shop_sell_mode = false shop_sell_mode = false
selected_skill_id = "" selected_skill_id = ""
selected_item_id = "" selected_item_id = ""
@@ -1491,6 +1541,7 @@ func _load_scenario(scenario_id: String) -> void:
battle_result_summary.clear() battle_result_summary.clear()
post_battle_dialogue_started = false post_battle_dialogue_started = false
floating_texts.clear() floating_texts.clear()
unit_motion_by_unit.clear()
selected_skill_id = "" selected_skill_id = ""
selected_item_id = "" selected_item_id = ""
_hide_tactic_menu() _hide_tactic_menu()
@@ -2370,6 +2421,7 @@ func _show_campaign_complete() -> void:
battle_result_summary.clear() battle_result_summary.clear()
post_battle_dialogue_started = true post_battle_dialogue_started = true
floating_texts.clear() floating_texts.clear()
unit_motion_by_unit.clear()
move_cells.clear() move_cells.clear()
attack_cells.clear() attack_cells.clear()
skill_cells.clear() skill_cells.clear()