diff --git a/README.md b/README.md index 023e90a..34bb1f6 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - New campaign save reset. - Victory and defeat result overlay. - 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. - Data-driven scenario setup through JSON definitions and deployments. ## Run diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0717dd7..0ffbedc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,7 +38,7 @@ 4. Show scenario briefing, including the optional pre-battle shop, Armory, Roster, and Formation setup. 5. Player selects a unit. 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 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 and floating combat text for core action and UI feedback. 8. Player ends turn. 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. @@ -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. -The battle scene owns presentation audio: briefing and result states use menu BGM, active battles use battle BGM, and log/result hooks trigger placeholder SFX 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, 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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f804771..4694209 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -132,7 +132,7 @@ - Portrait pipeline. - Unit sprites and animations. -- Battle effects. +- 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. - Chapter UI and visual novel scenes. diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index 091a461..bdb31a0 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -6,6 +6,7 @@ const DataCatalogScript := preload("res://scripts/core/data_catalog.gd") signal changed signal log_added(message: String) signal dialogue_requested(lines: Array) +signal combat_feedback_requested(unit_id: String, text: String, kind: String) const TEAM_PLAYER := "player" const TEAM_ENEMY := "enemy" @@ -848,6 +849,7 @@ func _resolve_skill_support(caster: Dictionary, target: Dictionary, skill_id: St target["name"], _format_support_effects(skill) ]) + _emit_combat_feedback(target, _format_support_popup(skill), "support") return true @@ -887,6 +889,24 @@ func _format_support_effects(skill: Dictionary) -> String: return _join_strings(parts, ", ") +func _format_support_popup(skill: Dictionary) -> String: + var parts := [] + for effect in skill.get("effects", []): + if typeof(effect) != TYPE_DICTIONARY: + continue + if str(effect.get("type", "")) != "stat_bonus": + continue + var stat := str(effect.get("stat", "")) + var amount := int(effect.get("amount", 0)) + if not _is_supported_status_stat(stat) or amount == 0: + continue + var sign := "+" if amount > 0 else "" + parts.append("%s %s%d" % [stat.to_upper(), sign, amount]) + if parts.is_empty(): + return "Support" + return _join_strings(parts, ", ") + + func _format_active_status_effects(unit: Dictionary) -> String: var parts := [] for effect in _active_status_effects(unit): @@ -1621,11 +1641,13 @@ func _apply_item_effects(user: Dictionary, target: Dictionary, item: Dictionary) if healed > 0: applied = true _emit_log("%s recovers %d HP." % [target["name"], healed]) + _emit_combat_feedback(target, "+%d HP" % healed, "heal") elif effect_type == "heal_mp": var restored := _apply_item_mp_heal(target, int(effect.get("amount", 0))) if restored > 0: applied = true _emit_log("%s recovers %d MP." % [target["name"], restored]) + _emit_combat_feedback(target, "+%d MP" % restored, "mp") if not applied: _emit_log("%s has no effect on %s." % [item.get("name", "Item"), target["name"]]) return applied @@ -1997,14 +2019,17 @@ func _resolve_attack(attacker: Dictionary, target: Dictionary, is_counter := fal var verb := "counterattacks" if is_counter else "attacks" if rng.randi_range(1, 100) > hit_chance: _emit_log("%s %s %s but misses. Hit %d%%." % [attacker["name"], verb, target["name"], hit_chance]) + _emit_combat_feedback(target, "MISS", "miss") return {"hit": false, "defeated": false} var damage := calculate_damage(attacker, target) target["hp"] = max(0, int(target["hp"]) - damage) _emit_log("%s %s %s for %d damage. Hit %d%%." % [attacker["name"], verb, target["name"], damage, hit_chance]) + _emit_combat_feedback(target, "-%d" % damage, "damage") if int(target["hp"]) <= 0: target["alive"] = false _emit_log("%s is defeated." % target["name"]) + _emit_combat_feedback(target, "DOWN", "defeat") return {"hit": true, "defeated": true} return {"hit": true, "defeated": false} @@ -2018,10 +2043,12 @@ func _resolve_skill_damage(caster: Dictionary, target: Dictionary, skill: Dictio target["name"], damage ]) + _emit_combat_feedback(target, "-%d" % damage, "damage") if int(target["hp"]) <= 0: target["alive"] = false _emit_log("%s is defeated." % target["name"]) + _emit_combat_feedback(target, "DOWN", "defeat") return true return false @@ -2035,6 +2062,7 @@ func _resolve_skill_heal(caster: Dictionary, target: Dictionary, skill: Dictiona target["name"], healed ]) + _emit_combat_feedback(target, "+%d HP" % healed, "heal") return healed @@ -2060,6 +2088,7 @@ func _level_up(unit: Dictionary) -> void: for stat in ["atk", "def", "int", "agi"]: unit[stat] = int(unit.get(stat, 0)) + _growth_gain(unit, stat, 1) _emit_log("%s reaches Lv.%d." % [unit["name"], unit["level"]]) + _emit_combat_feedback(unit, "Lv.%d" % int(unit["level"]), "progress") if str(unit.get("team", "")) == TEAM_PLAYER: _record_progression_event({ "type": "level_up", @@ -2095,6 +2124,7 @@ func _try_promote_unit(unit: Dictionary) -> void: unit["class_id"] = next_class_id _apply_class_runtime_fields(unit, next_class_id) _emit_log("%s promotes to %s." % [unit["name"], unit["class"]]) + _emit_combat_feedback(unit, "PROMOTE", "progress") if str(unit.get("team", "")) == TEAM_PLAYER: _record_progression_event({ "type": "promotion", @@ -2526,6 +2556,7 @@ func _expire_status_effects_for_team(team: String) -> void: unit["status_effects"] = kept_effects for expired_effect_name in expired_names: _emit_log("%s's %s fades." % [unit["name"], expired_effect_name]) + _emit_combat_feedback(unit, "FADES", "fade") func _manhattan(a: Vector2i, b: Vector2i) -> int: @@ -2549,5 +2580,14 @@ func _emit_log(message: String) -> void: log_added.emit(message) +func _emit_combat_feedback(unit: Dictionary, text: String, kind: String) -> void: + if unit.is_empty() or text.is_empty(): + return + var unit_id := str(unit.get("id", "")) + if unit_id.is_empty(): + return + combat_feedback_requested.emit(unit_id, text, kind) + + func _notify_changed() -> void: changed.emit() diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index c0ded61..12001d5 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -31,6 +31,8 @@ const SFX_VICTORY_STING := preload("res://audio/sfx/victory_sting_01.wav") const BGM_VOLUME_DB := -14.0 const SFX_VOLUME_DB := -4.0 const UI_SFX_VOLUME_DB := -8.0 +const FLOATING_TEXT_LIFETIME := 1.0 +const FLOATING_TEXT_RISE := 42.0 var state: BattleState = BattleStateScript.new() var campaign_state: CampaignState = CampaignStateScript.new() @@ -107,6 +109,7 @@ var active_dialogue_index := 0 var bgm_player: AudioStreamPlayer var current_bgm_key := "" var last_announced_battle_status := "" +var floating_texts: Array[Dictionary] = [] func _ready() -> void: @@ -115,6 +118,7 @@ func _ready() -> void: state.changed.connect(_on_state_changed) state.log_added.connect(_on_log_added) state.dialogue_requested.connect(_on_dialogue_requested) + state.combat_feedback_requested.connect(_on_combat_feedback_requested) campaign_state.load_campaign(CAMPAIGN_PATH) campaign_state.load_or_start(campaign_state.get_start_scenario_id()) if campaign_state.has_pending_post_battle_choice(): @@ -152,6 +156,7 @@ func _draw() -> void: _draw_map() _draw_overlays() _draw_units() + _draw_floating_texts() func _create_hud() -> void: @@ -732,6 +737,19 @@ func _handle_key(event: InputEventKey) -> void: _on_equip_pressed() +func _process(delta: float) -> void: + if floating_texts.is_empty(): + return + var active_texts: Array[Dictionary] = [] + for popup in floating_texts: + var next_popup := popup.duplicate(true) + next_popup["age"] = float(next_popup.get("age", 0.0)) + delta + if float(next_popup["age"]) < float(next_popup.get("duration", FLOATING_TEXT_LIFETIME)): + active_texts.append(next_popup) + floating_texts = active_texts + queue_redraw() + + func _can_select(unit: Dictionary) -> bool: if unit.is_empty(): return false @@ -818,6 +836,44 @@ func _draw_units() -> void: draw_arc(center, 29, 0.0, TAU, 48, Color(1.0, 0.92, 0.25), 3.0) +func _draw_floating_texts() -> void: + if floating_texts.is_empty(): + return + var font := ThemeDB.fallback_font + for popup in floating_texts: + var duration := max(0.01, float(popup.get("duration", FLOATING_TEXT_LIFETIME))) + var age := float(popup.get("age", 0.0)) + var progress := clampf(age / duration, 0.0, 1.0) + var alpha := 1.0 - progress + var color := _floating_text_color(str(popup.get("kind", ""))) + color.a = alpha + var shadow := Color(0.02, 0.02, 0.025, 0.82 * alpha) + var center = popup.get("pos", Vector2.ZERO) + var text := str(popup.get("text", "")) + var width := 118.0 + var draw_position := center - Vector2(width * 0.5, 24.0 + progress * FLOATING_TEXT_RISE) + draw_string(font, draw_position + Vector2(1, 1), text, HORIZONTAL_ALIGNMENT_CENTER, width, 18, shadow) + draw_string(font, draw_position, text, HORIZONTAL_ALIGNMENT_CENTER, width, 18, color) + + +func _floating_text_color(kind: String) -> Color: + if kind == "damage": + return Color(1.0, 0.30, 0.20) + if kind == "heal": + return Color(0.36, 1.0, 0.46) + if kind == "mp": + return Color(0.48, 0.74, 1.0) + if kind == "support": + return Color(1.0, 0.88, 0.28) + if kind == "miss" or kind == "fade": + return Color(0.82, 0.84, 0.90) + if kind == "defeat": + return Color(1.0, 0.18, 0.16) + if kind == "progress": + return Color(1.0, 0.78, 0.28) + return Color.WHITE + + func _cell_from_screen(screen_position: Vector2) -> Vector2i: var local := screen_position - BOARD_OFFSET if local.x < 0 or local.y < 0: @@ -1188,6 +1244,29 @@ func _on_dialogue_requested(lines: Array) -> void: _show_next_dialogue() +func _on_combat_feedback_requested(unit_id: String, text: String, kind: String) -> void: + var unit := state.get_unit(unit_id) + if unit.is_empty(): + return + var cell: Vector2i = unit.get("pos", Vector2i(-1, -1)) + if not state.is_inside(cell): + return + 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 + }) + queue_redraw() + + +func _floating_text_origin(cell: Vector2i) -> Vector2: + var rect := _rect_for_cell(cell) + return rect.position + Vector2(TILE_SIZE * 0.5, 18.0) + + func _normalized_dialogue_lines(lines: Array) -> Array: var result := [] for line in lines: @@ -1367,6 +1446,7 @@ func _on_restart_pressed() -> void: battle_result_applied = false battle_result_summary.clear() post_battle_dialogue_started = false + floating_texts.clear() shop_sell_mode = false selected_skill_id = "" selected_item_id = "" @@ -1410,6 +1490,7 @@ func _load_scenario(scenario_id: String) -> void: battle_result_applied = false battle_result_summary.clear() post_battle_dialogue_started = false + floating_texts.clear() selected_skill_id = "" selected_item_id = "" _hide_tactic_menu() @@ -2288,6 +2369,7 @@ func _show_campaign_complete() -> void: battle_result_applied = true battle_result_summary.clear() post_battle_dialogue_started = true + floating_texts.clear() move_cells.clear() attack_cells.clear() skill_cells.clear()