diff --git a/README.md b/README.md index bb006cb..7f1c499 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Floating combat text for damage, recovery, misses, support effects, poison ticks, level-ups, and promotions. - Short unit slide animation for board movement. - Hover target preview badges for attacks, tactics, and items. -- Low-HP board unit warning rings and HP bar color states. +- Low-HP board unit warning rings, HP bar color states, and board status pips for support, debuff, and poison effects. - Data-driven scenario setup through JSON definitions and deployments. ## Run diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8815995..e74b848 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. The pre-battle Chapters overview reads `CampaignState` chapter progress and can load completed or current battles without mutating the save. The pre-battle Save menu can write the current campaign state to `user://campaign_manual_save.json`, and loading that checkpoint restores it into `user://campaign_save.json` before re-entering the same pending-choice, completion, or current-briefing branch as startup. Pre-battle shop stock comes from the loaded scenario's `shop.items` list plus matching `shop.conditional_items` blocks, with optional finite limits from item entries or `stock` maps. Shop purchases and 50% sell-back are campaign transactions: they update saved gold, unequipped inventory, and finite-stock purchase counts 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 or cure matching damage-over-time statuses, 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, replay inventory consumption, save writes, current-scenario advancement, or finite-stock purchases; pre-battle Shop, Armory, Roster, Formation, and Save are disabled on completed-scenario replays to avoid side effects. -The battle scene owns presentation feedback: briefing and result states use menu BGM, briefing headers combine `CampaignState` chapter ranges, campaign order, and title data with `BattleState` briefing and objective text, active battles use battle BGM, dialogue lines can show optional portrait textures or speaker-initial fallback panels and can place the portrait on the left or right side of the dialogue panel, board units render low-HP warning rings and HP bar color states from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, log/result hooks trigger placeholder SFX, support, debuff, and poison effects use distinct floating feedback colors, `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 battle scene owns presentation feedback: briefing and result states use menu BGM, briefing headers combine `CampaignState` chapter ranges, campaign order, and title data with `BattleState` briefing and objective text, active battles use battle BGM, dialogue lines can show optional portrait textures or speaker-initial fallback panels and can place the portrait on the left or right side of the dialogue panel, board units render low-HP warning rings, HP bar color states, and active support/debuff/poison status pips from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, log/result hooks trigger placeholder SFX, support, debuff, and poison effects use distinct floating feedback colors, `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. diff --git a/docs/GODOT_4_6_MIGRATION.md b/docs/GODOT_4_6_MIGRATION.md index c32fd12..460827e 100644 --- a/docs/GODOT_4_6_MIGRATION.md +++ b/docs/GODOT_4_6_MIGRATION.md @@ -47,6 +47,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readines - Manual checkpoint save/load restores the expected current briefing without script errors. - A player unit can move, attack, wait, use a tactic, use an item, and equip compatible gear. - Poison Mist applies poison, poison ticks at the target team's phase start, and poison defeat immediately ends the battle when it satisfies a victory or defeat condition. +- Support, debuff, and poison status pips appear on affected board units and disappear when those effects expire or are cured. - Antidote can cure poison and does not consume stock when used on a target without a matching status. - Enemy turn advances and AI acts. - Victory and defeat panels still appear. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cd95dd4..a3c21d6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -132,7 +132,7 @@ ## Milestone 5: Presentation - Portrait pipeline. Officer definitions can provide default portrait paths, dialogue lines can override them, and missing art falls back to speaker initials. -- Unit sprites and animations. Board movement now has a short slide animation, and board units show low-HP warning rings. +- Unit sprites and animations. Board movement now has a short slide animation, and board units show low-HP warning rings plus small status pips for active support, debuff, and poison effects. - Battle effects. First floating combat text now covers damage, recovery, misses, support effects, poison ticks, level-ups, and promotions. Hover target preview badges summarize attacks, tactics, and items. - 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. Pre-battle briefing now uses campaign chapter ranges for chapter-aware numbering, has a chapter progress/replay overview, and dialogue lines can choose left or right portrait placement. diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 5562197..a074666 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -36,6 +36,10 @@ const FLOATING_TEXT_RISE := 42.0 const UNIT_MOVE_ANIMATION_DURATION := 0.18 const TARGET_PREVIEW_BADGE_SIZE := Vector2(92, 22) const LOW_HP_WARNING_RATIO := 0.35 +const UNIT_STATUS_MARKER_RADIUS := 4.0 +const UNIT_STATUS_MARKER_GAP := 9.0 +const UNIT_STATUS_MARKER_OFFSET_Y := 14.0 +const MAX_UNIT_STATUS_MARKERS := 4 var state: BattleState = BattleStateScript.new() var campaign_state: CampaignState = CampaignStateScript.new() @@ -981,7 +985,7 @@ func _draw_units() -> void: var hp_front := Rect2(hp_back.position, Vector2(hp_back.size.x * hp_ratio, hp_back.size.y)) draw_rect(hp_back, Color(0.08, 0.08, 0.09)) draw_rect(hp_front, _unit_hp_color(hp_ratio)) - _draw_unit_status_markers(center, hp_ratio) + _draw_unit_status_markers(center, hp_ratio, unit) if unit.get("id", "") == state.selected_unit_id: draw_arc(center, 29, 0.0, TAU, 48, Color(1.0, 0.92, 0.25), 3.0) @@ -995,10 +999,66 @@ func _unit_hp_color(hp_ratio: float) -> Color: return Color(0.28, 0.82, 0.36) -func _draw_unit_status_markers(center: Vector2, hp_ratio: float) -> void: +func _draw_unit_status_markers(center: Vector2, hp_ratio: float, unit: Dictionary) -> void: if hp_ratio <= LOW_HP_WARNING_RATIO: draw_arc(center, 27, 0.0, TAU, 48, Color(1.0, 0.28, 0.14), 3.0) + var marker_kinds := _unit_status_marker_kinds(unit) + if marker_kinds.is_empty(): + return + + var start_x := -float(marker_kinds.size() - 1) * UNIT_STATUS_MARKER_GAP * 0.5 + for index in range(marker_kinds.size()): + var marker_center := center + Vector2(start_x + float(index) * UNIT_STATUS_MARKER_GAP, UNIT_STATUS_MARKER_OFFSET_Y) + draw_circle(marker_center, UNIT_STATUS_MARKER_RADIUS + 1.5, Color(0.02, 0.022, 0.028, 0.88)) + draw_circle(marker_center, UNIT_STATUS_MARKER_RADIUS, _unit_status_marker_color(marker_kinds[index])) + + +func _unit_status_marker_kinds(unit: Dictionary) -> Array[String]: + var result: Array[String] = [] + var status_effects = unit.get("status_effects", []) + if typeof(status_effects) != TYPE_ARRAY: + return result + + for effect in status_effects: + if typeof(effect) != TYPE_DICTIONARY: + continue + if int(effect.get("remaining_phases", 0)) <= 0: + continue + + var marker_kind := _unit_status_marker_kind(effect) + if marker_kind.is_empty() or result.has(marker_kind): + continue + + result.append(marker_kind) + if result.size() >= MAX_UNIT_STATUS_MARKERS: + break + + return result + + +func _unit_status_marker_kind(effect: Dictionary) -> String: + var effect_type := str(effect.get("type", "stat_bonus")) + if effect_type == "damage_over_time" and int(effect.get("amount", 0)) > 0: + return str(effect.get("status", "poison")) + if effect_type == "stat_bonus": + var amount := int(effect.get("amount", 0)) + if amount > 0: + return "support" + if amount < 0: + return "debuff" + return "" + + +func _unit_status_marker_color(marker_kind: String) -> Color: + if marker_kind == "support": + return Color(1.0, 0.88, 0.30) + if marker_kind == "debuff": + return Color(0.78, 0.55, 1.0) + if marker_kind == "poison": + return Color(0.54, 0.95, 0.36) + return Color(0.72, 0.86, 1.0) + func _visual_rect_for_unit(unit: Dictionary) -> Rect2: var cell: Vector2i = unit.get("pos", Vector2i(-1, -1))