diff --git a/README.md b/README.md index 86d1cd7..e73fc3d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Weapon, armor, and accessory rewards can be stored, displayed, compared, equipped, and unequipped from the battle HUD; some weapons deal bonus physical damage against matching move types. - First named equipment rewards exist in the northern campaign arc and show a `[Named]` or `Named` tag in reward, inventory, shop, Armory, and equipment menus. - Scenario-specific pre-battle shop purchases, optional finite shop stock, and 50% sell-back save immediately and sync into the upcoming battle inventory. +- Pre-battle Talk can play scenario camp dialogue, and shop stock can include merchant flavor lines before a battle. - Pre-battle Armory equipment changes, including unequipping gear back into stock, save immediately and sync roster equipment plus inventory stock. - Pre-battle Roster can move optional officers between sortie and reserve within scenario limits. - Pre-battle Formation lets deployed officers swap starting positions inside scenario-defined cells. @@ -113,11 +114,14 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Victory and defeat result overlay. - Dialogue portrait slot with officer default image paths, optional per-line overrides, and speaker-initial fallback. - Initial AI-generated photorealistic officer portraits for Cao Cao, Xiahou Dun, Xiahou Yuan, Cao Ren, Dian Wei, Guo Jia, and Zhang He. +- Initial AI-generated battlefield, class unit, and item icon assets live under `art/backgrounds`, `art/units`, and `art/items`. - Battle HUD portrait thumbnails show the selected unit, or a hovered unit when nothing is selected. - Dialogue lines can place the portrait on the left or right side of an expanded visual-novel-style dialogue panel with progress and previous-line controls. - Placeholder menu/battle BGM plus UI, movement, attack, tactic, item, victory, and defeat SFX. - Floating combat text for damage, recovery, misses, support effects, poison ticks, action locks, level-ups, and promotions. - Short unit slide animation for board movement. +- Board attacks now request a short strike animation, with melee lunges and ranged attack traces drawn by the battle scene. +- Battle maps can use a high-resolution background image with translucent terrain overlays and terrain-specific detail patterns. - Hover target preview badges for attacks, tactics, and items. - Low-HP board unit warning rings, HP bar color states, and board status pips plus HUD duration summaries for support, debuff, poison, seal, snare, and disarm effects. - Data-driven scenario setup through JSON definitions and deployments. @@ -128,7 +132,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr 2. Open this folder as a Godot project. 3. Run the project. The main scene is `res://scenes/battle_scene.tscn`. -Campaign order and chapter ranges live at `res://data/campaign/campaign.json`. Scenarios live under `res://data/scenarios/`, with reusable officers, classes, items, skills, and terrain definitions under `res://data/defs/`. Generated portrait assets live under `res://art/portraits/`. Placeholder BGM and SFX live under `res://audio/`. +Campaign order and chapter ranges live at `res://data/campaign/campaign.json`. Scenarios live under `res://data/scenarios/`, with reusable officers, classes, items, skills, and terrain definitions under `res://data/defs/`. Generated visual assets live under `res://art/portraits/`, `res://art/units/`, `res://art/items/`, and `res://art/backgrounds/`. Placeholder BGM and SFX live under `res://audio/`. Data files can be checked with `powershell -NoProfile -ExecutionPolicy Bypass -File tools\validate_data.ps1`. diff --git a/art/backgrounds/battlefield_frontier.png b/art/backgrounds/battlefield_frontier.png new file mode 100644 index 0000000..1138cfd Binary files /dev/null and b/art/backgrounds/battlefield_frontier.png differ diff --git a/art/items/armor.png b/art/items/armor.png new file mode 100644 index 0000000..d9c2f76 Binary files /dev/null and b/art/items/armor.png differ diff --git a/art/items/axe.png b/art/items/axe.png new file mode 100644 index 0000000..20d0229 Binary files /dev/null and b/art/items/axe.png differ diff --git a/art/items/bow.png b/art/items/bow.png new file mode 100644 index 0000000..4b780f9 Binary files /dev/null and b/art/items/bow.png differ diff --git a/art/items/panacea.png b/art/items/panacea.png new file mode 100644 index 0000000..66266df Binary files /dev/null and b/art/items/panacea.png differ diff --git a/art/items/spear.png b/art/items/spear.png new file mode 100644 index 0000000..635ba40 Binary files /dev/null and b/art/items/spear.png differ diff --git a/art/items/sword.png b/art/items/sword.png new file mode 100644 index 0000000..851e300 Binary files /dev/null and b/art/items/sword.png differ diff --git a/art/units/archer.png b/art/units/archer.png new file mode 100644 index 0000000..ebd4e64 Binary files /dev/null and b/art/units/archer.png differ diff --git a/art/units/cavalry.png b/art/units/cavalry.png new file mode 100644 index 0000000..d7caf9d Binary files /dev/null and b/art/units/cavalry.png differ diff --git a/art/units/infantry.png b/art/units/infantry.png new file mode 100644 index 0000000..362eaf4 Binary files /dev/null and b/art/units/infantry.png differ diff --git a/art/units/strategist.png b/art/units/strategist.png new file mode 100644 index 0000000..692a41b Binary files /dev/null and b/art/units/strategist.png differ diff --git a/art/units/warrior.png b/art/units/warrior.png new file mode 100644 index 0000000..4ebd5b9 Binary files /dev/null and b/art/units/warrior.png differ diff --git a/data/defs/classes.json b/data/defs/classes.json index 8f768ff..8a69b99 100644 --- a/data/defs/classes.json +++ b/data/defs/classes.json @@ -1,6 +1,7 @@ { "hero": { "name": "Hero", + "sprite": "res://art/units/strategist.png", "tier": 1, "move_type": "foot", "move": 4, @@ -17,6 +18,7 @@ }, "commander": { "name": "Commander", + "sprite": "res://art/units/strategist.png", "tier": 2, "move_type": "foot", "move": 5, @@ -32,6 +34,7 @@ }, "cavalry": { "name": "Cavalry", + "sprite": "res://art/units/cavalry.png", "tier": 1, "move_type": "mounted", "move": 5, @@ -48,6 +51,7 @@ }, "elite_cavalry": { "name": "Elite Cavalry", + "sprite": "res://art/units/cavalry.png", "tier": 2, "move_type": "mounted", "move": 6, @@ -63,6 +67,7 @@ }, "infantry": { "name": "Infantry", + "sprite": "res://art/units/infantry.png", "tier": 1, "move_type": "foot", "move": 4, @@ -79,6 +84,7 @@ }, "guard_captain": { "name": "Guard Captain", + "sprite": "res://art/units/infantry.png", "tier": 2, "move_type": "foot", "move": 5, @@ -94,6 +100,7 @@ }, "warrior": { "name": "Warrior", + "sprite": "res://art/units/warrior.png", "tier": 1, "move_type": "foot", "move": 4, @@ -110,6 +117,7 @@ }, "champion": { "name": "Champion", + "sprite": "res://art/units/warrior.png", "tier": 2, "move_type": "foot", "move": 5, @@ -125,6 +133,7 @@ }, "archer": { "name": "Archer", + "sprite": "res://art/units/archer.png", "tier": 1, "move_type": "archer", "move": 4, @@ -141,6 +150,7 @@ }, "marksman": { "name": "Marksman", + "sprite": "res://art/units/archer.png", "tier": 2, "move_type": "archer", "move": 5, @@ -156,6 +166,7 @@ }, "strategist": { "name": "Strategist", + "sprite": "res://art/units/strategist.png", "tier": 1, "move_type": "foot", "move": 4, @@ -172,6 +183,7 @@ }, "military_advisor": { "name": "Military Advisor", + "sprite": "res://art/units/strategist.png", "tier": 2, "move_type": "foot", "move": 4, @@ -187,6 +199,7 @@ }, "bandit": { "name": "Bandit", + "sprite": "res://art/units/warrior.png", "tier": 1, "move_type": "foot", "move": 4, diff --git a/data/defs/items.json b/data/defs/items.json index 70ad613..b867a3c 100644 --- a/data/defs/items.json +++ b/data/defs/items.json @@ -2,6 +2,7 @@ "bronze_sword": { "name": "Bronze Sword", "kind": "weapon", + "icon": "res://art/items/sword.png", "weapon_type": "sword", "range": [1, 1], "bonuses": { "atk": 3 }, @@ -11,6 +12,7 @@ "iron_sword": { "name": "Iron Sword", "kind": "weapon", + "icon": "res://art/items/sword.png", "weapon_type": "sword", "range": [1, 1], "bonuses": { "atk": 5 }, @@ -20,6 +22,7 @@ "training_spear": { "name": "Training Spear", "kind": "weapon", + "icon": "res://art/items/spear.png", "weapon_type": "spear", "range": [1, 1], "bonuses": { "atk": 4 }, @@ -31,6 +34,7 @@ "short_bow": { "name": "Short Bow", "kind": "weapon", + "icon": "res://art/items/bow.png", "weapon_type": "bow", "range": [2, 2], "bonuses": { "atk": 3 }, @@ -42,6 +46,7 @@ "hand_axe": { "name": "Hand Axe", "kind": "weapon", + "icon": "res://art/items/axe.png", "weapon_type": "axe", "range": [1, 1], "bonuses": { "atk": 4 }, @@ -53,6 +58,7 @@ "war_axe": { "name": "War Axe", "kind": "weapon", + "icon": "res://art/items/axe.png", "weapon_type": "axe", "range": [1, 1], "bonuses": { "atk": 6 }, @@ -64,6 +70,7 @@ "cloth_robe": { "name": "Cloth Robe", "kind": "armor", + "icon": "res://art/items/armor.png", "armor_type": "robe", "bonuses": { "def": 1 }, "effects": [], @@ -72,6 +79,7 @@ "leather_armor": { "name": "Leather Armor", "kind": "armor", + "icon": "res://art/items/armor.png", "armor_type": "light_armor", "bonuses": { "def": 1 }, "effects": [], @@ -80,6 +88,7 @@ "iron_armor": { "name": "Iron Armor", "kind": "armor", + "icon": "res://art/items/armor.png", "armor_type": "heavy_armor", "bonuses": { "def": 3 }, "effects": [], @@ -105,6 +114,7 @@ "name": "Yitian Sword", "kind": "weapon", "rarity": "named", + "icon": "res://art/items/sword.png", "weapon_type": "sword", "range": [1, 1], "bonuses": { "atk": 7, "int": 1 }, @@ -117,6 +127,7 @@ "name": "Dragon Spear", "kind": "weapon", "rarity": "named", + "icon": "res://art/items/spear.png", "weapon_type": "spear", "range": [1, 1], "bonuses": { "atk": 7, "agi": 1 }, @@ -129,6 +140,7 @@ "name": "Wind Chaser Bow", "kind": "weapon", "rarity": "named", + "icon": "res://art/items/bow.png", "weapon_type": "bow", "range": [2, 2], "bonuses": { "atk": 5, "agi": 2 }, @@ -141,6 +153,7 @@ "name": "Black Tortoise Robe", "kind": "armor", "rarity": "named", + "icon": "res://art/items/armor.png", "armor_type": "robe", "bonuses": { "mp": 2, "def": 3, "int": 1 }, "effects": [], @@ -149,6 +162,7 @@ "bean": { "name": "Bean", "kind": "consumable", + "icon": "res://art/items/panacea.png", "uses": 1, "effects": [{ "type": "heal_hp", "amount": 20 }], "price": 50 @@ -156,6 +170,7 @@ "antidote": { "name": "Antidote", "kind": "consumable", + "icon": "res://art/items/panacea.png", "target": "ally", "range": [0, 1], "uses": 1, @@ -165,6 +180,7 @@ "panacea": { "name": "Panacea", "kind": "consumable", + "icon": "res://art/items/panacea.png", "target": "ally", "range": [0, 1], "uses": 1, @@ -180,6 +196,7 @@ "wine": { "name": "Wine", "kind": "consumable", + "icon": "res://art/items/panacea.png", "uses": 1, "effects": [{ "type": "heal_mp", "amount": 8 }], "price": 120 diff --git a/data/defs/terrain.json b/data/defs/terrain.json index 1cdea59..f01c465 100644 --- a/data/defs/terrain.json +++ b/data/defs/terrain.json @@ -38,6 +38,13 @@ "defense": 0, "avoid": 0, "move_cost": { "foot": 99, "mounted": 99, "archer": 99, "water": 1 } + }, + "C": { + "id": "castle", + "name": "Castle", + "color": "#7f7a70", + "defense": 3, + "avoid": 0, + "move_cost": { "foot": 1, "mounted": 2, "archer": 1, "water": 99 } } } - diff --git a/data/scenarios/001_yellow_turbans.json b/data/scenarios/001_yellow_turbans.json index 0ec4e45..0a7c367 100644 --- a/data/scenarios/001_yellow_turbans.json +++ b/data/scenarios/001_yellow_turbans.json @@ -18,9 +18,20 @@ "lines": [ "Yellow Turban rebels have gathered near Yingchuan.", "Cao Cao and Xiahou Dun must break their formation before the revolt spreads." + ], + "camp_dialogue": [ + { "speaker": "Cao Cao", "side": "left", "text": "Yingchuan is a spark in dry grass. If we move decisively, the court may still believe order can return." }, + { "speaker": "Xiahou Dun", "side": "right", "text": "The men are ready. Give me the front, and I will keep the rebels fixed while you cut their nerve." } ] }, "shop": { + "merchant": { + "name": "Camp Merchant", + "lines": [ + "Roadside stores are thin, my lord, but I saved beans and antidotes for your column.", + "The northern woods have rumors of a hidden cache, if your scouts can reach it." + ] + }, "items": ["bean", "antidote"] }, "roster": { @@ -33,9 +44,10 @@ "map": { "width": 10, "height": 8, + "background": "res://art/backgrounds/battlefield_frontier.png", "terrain": [ - "GGGGGGGGGG", - "GGGFFFFGGG", + "GGGGGGGGCC", + "GGGFFFFGCC", "GGGFFFGGGG", "GGGRRGGGGG", "GGGRRGGHHG", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index db31002..9eec805 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,7 +16,7 @@ - `scripts/core/battle_state.gd`: Battle rules, unit state, turns, AI, and victory checks. - `scripts/scenes/battle_scene.gd`: Rendering, input handling, and HUD wiring. - `audio/bgm/*.wav` and `audio/sfx/*.wav`: Placeholder music loops and interface/action stingers. -- `art/portraits/*.png`: Generated officer portrait textures used by dialogue and battle HUD presentation. +- `art/portraits/*.png`, `art/units/*.png`, `art/items/*.png`, and `art/backgrounds/*.png`: Generated visual assets used by dialogue, battle HUD, board tokens, item buttons, and map backdrops. - `data/campaign/campaign.json`: Campaign chapter ranges, scenario order, and scenario paths. - `data/defs/*.json`: Officers, classes, terrain, items, and skills. - `data/scenarios/*.json`: Scenario definitions. @@ -36,10 +36,10 @@ 1. Load battle data. 2. Hydrate deployments through `DataCatalog`. 3. Dispatch battle-start scenario events. -4. Show scenario briefing with the campaign chapter header, battle header, objective summary, chapter overview, manual checkpoint menu, and optional pre-battle shop, Armory, Roster, and Formation setup. +4. Show scenario briefing with the campaign chapter header, battle header, objective summary, chapter overview, camp Talk dialogue, manual checkpoint menu, and 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, area radius, and power, support, debuff, movement modifier, accuracy/evasion modifier, damage-over-time, or action-lock effect. Physical attacks roll hit chance from unit agility and target terrain avoid, and equipped weapons can add small damage bonuses against matching target move types; misses give reduced EXP. The scene plays placeholder SFX, a short movement slide, low-HP warning rings, hover target preview badges, area tactic overlays, an enemy threat overlay with physical damage and hostile tactic hints, hover unit class/movement/equipment details, 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, area radius, and power, support, debuff, movement modifier, accuracy/evasion modifier, damage-over-time, or action-lock effect. Physical attacks roll hit chance from unit agility and target terrain avoid, and equipped weapons can add small damage bonuses against matching target move types; misses give reduced EXP. The scene plays placeholder SFX, a short movement slide, a short attack motion, low-HP warning rings, hover target preview badges, area tactic overlays, an enemy threat overlay with physical damage and hostile tactic hints, hover unit class/movement/equipment details, and floating combat text for core action and UI feedback. 8. Player ends turn. 9. Enemy AI moves, picks physical attacks from damage, defeat, priority, and weapon-effectiveness scores, and can cast single-target or area damage, healing, support, debuff, movement-modifier, accuracy/evasion-modifier, damage-over-time, or action-lock 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. @@ -57,8 +57,8 @@ Joined officers gate whether `requires_joined` player deployments are loaded at all. Rewards and post-battle choices can add or remove joined officers without deleting their saved roster snapshot, so a later rejoin can preserve progression. When a save loads, `CampaignState` scans completed scenarios in campaign order and reapplies only officer join/leave transitions from rewards and the saved `applied_post_battle_choices` ledger, allowing added recruitment rewards to reach older saves without replaying gold or items. Old saves without the ledger can backfill a choice id only when exactly one completed scenario choice matches saved flags. Roster entries are keyed by `officer_id` when available, so future scenarios can deploy the same joined officer under different scenario-specific `unit_id` values. Saved progression overlays final battle stats for now; a later equipment refactor should split base stats from equipment bonuses before adding item leveling or stat recalculation. New battles currently start player units healed to their saved maximum HP/MP. Skill access is carried in roster snapshots, with class defaults still applied for old saves that do not include skills. Promoted class ids are saved in the roster, and the next battle rehydrates class-derived movement, growth, skills, and range from the saved class id before equipment range is recalculated. -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. Briefings preview first-clear rewards and show completed replay rewards as already claimed. 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, and `set_ai_target_priority` events can retune that battle-only pressure after an ambush, gate closure, or escort exposure. 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, and can be expressed as one `pos` or a multi-cell `cells` list; gated victory conditions can also expose the `unit_reaches_tile` event that unlocks them as the next visible objective marker. Movement-triggered `unit_reaches_tile` events receive the unit that just moved, so ambushes and pickup events fire on entry rather than from units already standing on a marker; `unit_defeated` events receive the fallen unit before final victory/defeat checks, allowing named-officer reactions and last-moment reinforcements. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, item and gold pickups, AI target-priority shifts, and reinforcements from saved campaign choices or `when.after_event` to wait for earlier scenario beats. Consumable use can restore HP or MP, cure matching poison/seal/snare/disarm statuses, or cleanse negative stat debuffs, and in-battle item use, event pickup grants, event gold pickups, plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items, keep picked-up items or gold, 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. Named equipment currently uses the same stock model with a presentation-only rarity tag rather than unique item instances. Already completed scenarios can be replayed without granting duplicate rewards, choices, replay inventory consumption, event gold pickups, 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. +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. Briefings preview first-clear rewards, can expose `briefing.camp_dialogue` through the Talk button, and show completed replay rewards as already claimed. 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, and `shop.merchant` lines are presentation-only camp flavor. 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, and `set_ai_target_priority` events can retune that battle-only pressure after an ambush, gate closure, or escort exposure. 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, and can be expressed as one `pos` or a multi-cell `cells` list; gated victory conditions can also expose the `unit_reaches_tile` event that unlocks them as the next visible objective marker. Movement-triggered `unit_reaches_tile` events receive the unit that just moved, so ambushes and pickup events fire on entry rather than from units already standing on a marker; `unit_defeated` events receive the fallen unit before final victory/defeat checks, allowing named-officer reactions and last-moment reinforcements. Briefing data can append matching `briefing.conditional_lines`, and scenario events can use `when.campaign_flags` to branch dialogue, objective changes, item and gold pickups, AI target-priority shifts, and reinforcements from saved campaign choices or `when.after_event` to wait for earlier scenario beats. Consumable use can restore HP or MP, cure matching poison/seal/snare/disarm statuses, or cleanse negative stat debuffs, and in-battle item use, event pickup grants, event gold pickups, plus equipment swaps mutate only the battle copy until victory; defeats and restarts do not spend saved items, keep picked-up items or gold, 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. Named equipment currently uses the same stock model with a presentation-only rarity tag rather than unique item instances. Already completed scenarios can be replayed without granting duplicate rewards, choices, replay inventory consumption, event gold pickups, 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, objective text, condition-progress text, and defeat-risk text, active battles use battle BGM and show an objective/progress/risk panel fed by `BattleState`, objective-update signals show a short HUD notice and refresh the objective panel immediately, dialogue lines can show optional cached portrait textures or speaker-initial fallback panels in an expanded visual-novel-style panel, can place the portrait on the left or right side of that panel, and expose progress plus previous-line controls while a sequence is open, the side HUD reuses cached officer portrait textures for the selected unit or hovered unit, board units render low-HP warning rings, HP bar color states, and active support/debuff/poison/seal/snare/disarm status pips from battle state fields, hover previews are rendered as target badges from existing `BattleState` forecast APIs, hover unit info shows class, movement type, AGI, movement, range, and equipped gear, equipment option menus show stat/range/effect deltas, result and inventory summaries show named equipment with compact rarity tags, selected area tactics highlight their affected cells, the Threat toggle overlays enemy physical and hostile tactic reach while tile info names threat sources, estimates physical damage, and summarizes hostile tactic damage or status effects against occupied cells, log/result hooks trigger placeholder SFX, support, debuff, poison, seal, snare, disarm, objective updates, AI target-focus changes, and item/gold pickup effects use distinct feedback, `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, objective text, condition-progress text, and defeat-risk text, active battles use battle BGM and show an objective/progress/risk panel fed by `BattleState`, objective-update signals show a short HUD notice and refresh the objective panel immediately, dialogue lines can show optional cached portrait textures or speaker-initial fallback panels in an expanded visual-novel-style panel, can place the portrait on the left or right side of that panel, and expose progress plus previous-line controls while a sequence is open, the side HUD reuses cached officer portrait textures or class sprites for the selected unit or hovered unit, board maps can draw a scenario background texture under translucent terrain overlays and terrain detail patterns, board units render class sprite tokens, team rings, class badges, low-HP warning rings, HP bar color states, and active support/debuff/poison/seal/snare/disarm status pips from battle state fields, item buttons can show optional cached item icons, hover previews are rendered as target badges from existing `BattleState` forecast APIs, hover unit info shows class, movement type, AGI, movement, range, and equipped gear, equipment option menus show stat/range/effect deltas, result and inventory summaries show named equipment with compact rarity tags, selected area tactics highlight their affected cells, the Threat toggle overlays enemy physical and hostile tactic reach while tile info names threat sources, estimates physical damage, and summarizes hostile tactic damage or status effects against occupied cells, log/result hooks trigger placeholder SFX, support, debuff, poison, seal, snare, disarm, objective updates, AI target-focus changes, and item/gold pickup effects use distinct feedback, `BattleState.unit_motion_requested` asks the scene to interpolate a unit's draw position during movement, `BattleState.unit_action_motion_requested` asks the scene to render a short attack motion, 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/DATA_MODEL.md b/docs/DATA_MODEL.md index 1c004bf..e79f946 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -200,6 +200,7 @@ Classes provide movement, min/max attack range, skill access, growth grades, cla { "cavalry": { "name": "Cavalry", + "sprite": "res://art/units/cavalry.png", "tier": 1, "move_type": "mounted", "move": 5, @@ -218,6 +219,7 @@ Classes provide movement, min/max attack range, skill access, growth grades, cla ``` `attack_range` is `[min, max]` in Manhattan distance. A range of `[2, 2]` means the unit cannot attack adjacent targets. +`sprite` is an optional class-level board token image used by hydrated units unless an officer or deployment overrides it. `promotion.level` and `promotion.to` define an automatic class promotion route. When a unit reaches the threshold during battle, the runtime updates class name, movement, movement type, growth, class skills, attack range, and class base-bonus deltas. The promoted `class_id` and derived roster fields are captured in the campaign roster snapshot, and the next battle rehydrates class-derived fields from the saved `class_id`. Promotion targets must be higher-tier classes and must keep all source equipment slot permissions unless runtime auto-unequip logic is added later. The current core routes cover hero, cavalry, infantry, archer, warrior, and strategist first-tier classes. @@ -310,6 +312,7 @@ Items start as static bonuses and consumable effects. Equipment leveling should "bronze_sword": { "name": "Bronze Sword", "kind": "weapon", + "icon": "res://art/items/sword.png", "weapon_type": "sword", "range": [1, 1], "bonuses": { "atk": 3 }, @@ -320,6 +323,7 @@ Items start as static bonuses and consumable effects. Equipment leveling should ``` Weapon `range` also uses `[min, max]`. When class and equipment both provide range, the runtime unit keeps the lowest minimum and highest maximum. Weapons can optionally define `effective_vs_move_types` plus `effective_bonus_damage` to add a small physical damage bonus during combat against targets with a matching runtime `move_type`; this is shown in attack forecasts, combat logs, shop stock, and equipment menus, but it does not mutate saved unit stats. The first examples are spear vs mounted, bow vs water, and axe vs foot. Accessories use `kind: "accessory"` with an `accessory_type` checked against the class `equipment_slots.accessory` list; `war_drum` and `imperial_seal` are the first branch-choice accessory rewards. Current equipment inventory is stackable stock only; unique equipment instances, durability, and enhancement are planned later. `war_axe` is a stronger axe reward for the Warrior/Bandit weapon family. The first named rewards are `yitian_sword`, `dragon_spear`, `wind_chaser_bow`, and `black_tortoise_robe`; their `rarity` tag is presentation-only and does not enforce uniqueness. +`icon` is an optional item UI image shown in shop, item, and equipment buttons when present. Consumables use `kind: "consumable"` with effect records. `heal_hp` restores HP, `heal_mp` restores MP, `cure_status` removes active battle-only effects with a matching `status` from a valid target, and `cleanse_debuffs` removes only negative `stat_bonus` effects while leaving positive buffs intact; `Bean`, `Wine`, `Antidote`, and `Panacea` are the first examples. Item use consumes the acting unit's action and only commits to the save file on victory. Weapons, armor, and accessories can be granted through `rewards.items`; they are shown in inventory, can be equipped through the Equip menu before the unit moves or acts, and are not usable from the battle item menu. @@ -343,7 +347,7 @@ Consumables use `kind: "consumable"` with effect records. `heal_hp` restores HP, } ``` -Scenario `shop.items` controls which positive-price items appear in the current pre-battle shop. `shop.conditional_items` can append extra stock when saved campaign flags match. Entries can be plain item ids or objects with `id` and optional non-negative `stock`; `shop.stock` and conditional block `stock` objects can also set finite limits by item id. Items omitted from stock data are unlimited. Finite stock is tracked as per-scenario purchase counts in the campaign save, so buying an item decrements that scenario's remaining stock and manual checkpoint saves preserve it. Purchases spend campaign gold, increment the campaign inventory count, save immediately, and then refresh the battle inventory copy if a scenario briefing is already loaded. The shop can also sell unequipped inventory stock back for 50% of base `price`, saving immediately and refreshing the loaded battle inventory copy. Later campaign data should add broader unlock flags. +Scenario `shop.items` controls which positive-price items appear in the current pre-battle shop. `shop.merchant` can provide a `name` and `lines` that are shown above buy stock as a lightweight camp event. `shop.conditional_items` can append extra stock when saved campaign flags match. Entries can be plain item ids or objects with `id` and optional non-negative `stock`; `shop.stock` and conditional block `stock` objects can also set finite limits by item id. Items omitted from stock data are unlimited. Finite stock is tracked as per-scenario purchase counts in the campaign save, so buying an item decrements that scenario's remaining stock and manual checkpoint saves preserve it. Purchases spend campaign gold, increment the campaign inventory count, save immediately, and then refresh the battle inventory copy if a scenario briefing is already loaded. The shop can also sell unequipped inventory stock back for 50% of base `price`, saving immediately and refreshing the loaded battle inventory copy. Later campaign data should add broader unlock flags. The pre-battle Armory operates on the currently loaded player deployments and the copied battle inventory, then saves the changed roster entries and inventory counts back into `CampaignState`. Gear can also be unequipped back into inventory stock before combat, making it available for later Armory swaps or shop selling. Unlike in-battle equipment swaps, Armory changes persist immediately and survive defeat or restart. Completed-scenario replays keep Shop and Armory disabled so replay setup cannot alter the campaign save. @@ -355,9 +359,9 @@ Scenario `formation.cells` controls the highlighted starting cells available dur ## Scenarios -Scenarios should use deployments rather than full inline unit stat blocks. `DataCatalog` will resolve officer, class, and item references into runtime unit dictionaries. Scenarios may also define briefing text, visible objective text, battle conditions, and victory rewards. +Scenarios should use deployments rather than full inline unit stat blocks. `DataCatalog` will resolve officer, class, and item references into runtime unit dictionaries. Scenarios may also define briefing text, camp dialogue, visible objective text, battle conditions, map background art, and victory rewards. -Scenario deployments may override `move_type` for special units. For example, a river boat can keep an existing combat class but set `"move_type": "water"` so it can deploy and path on `W` water tiles while normal foot, mounted, and archer units treat those cells as impassable. Deployments can also override `portrait` and `equipment`; the validator checks portrait files and equipment against class equipment slots, and `DataCatalog` applies their presentation paths, stat bonuses, and weapon ranges when hydrating the unit. +Scenario deployments may override `move_type` for special units. For example, a river boat can keep an existing combat class but set `"move_type": "water"` so it can deploy and path on `W` water tiles while normal foot, mounted, and archer units treat those cells as impassable. Deployments can also override `portrait`, `sprite`, and `equipment`; the validator checks image files and equipment against class equipment slots, and `DataCatalog` applies their presentation paths, stat bonuses, and weapon ranges when hydrating the unit. ```json { @@ -375,6 +379,9 @@ Scenario deployments may override `move_type` for special units. For example, a "title": "Yingchuan Skirmish", "location": "Yingchuan, 184 CE", "lines": ["Yellow Turban rebels have gathered near Yingchuan."], + "camp_dialogue": [ + { "speaker": "Cao Cao", "side": "left", "text": "We settle this before the revolt spreads." } + ], "conditional_lines": [ { "campaign_flags": { "pursued_dong_zhuo": true }, @@ -383,6 +390,10 @@ Scenario deployments may override `move_type` for special units. For example, a ] }, "shop": { + "merchant": { + "name": "Camp Merchant", + "lines": ["I saved beans and antidotes for your column."] + }, "items": ["bean", { "id": "cloth_robe", "stock": 1 }], "stock": { "bean": 3 }, "conditional_items": [ @@ -401,6 +412,21 @@ Scenario deployments may override `move_type` for special units. For example, a "formation": { "cells": [[1, 3], [1, 4], [2, 3], [2, 4]] }, + "map": { + "width": 10, + "height": 8, + "background": "res://art/backgrounds/battlefield_frontier.png", + "terrain": [ + "GGGGGGGGCC", + "GGGFFFFGCC", + "GGGFFFGGGG", + "GGGRRGGGGG", + "GGGRRGGHHG", + "GGGGGGGHHG", + "GGGWWGGGGG", + "GGGWWGGGGG" + ] + }, "deployments": [ { "unit_id": "cao_cao", "officer_id": "cao_cao", "team": "player", "pos": [1, 3] }, { "unit_id": "protected_envoy", "name": "Protected Envoy", "class_id": "infantry", "team": "player", "controllable": false, "persist_progression": false, "ai_target_priority": 6, "pos": [1, 4] }, @@ -601,4 +627,4 @@ Before a battle starts, `BattleScene` can buy priced items through `CampaignStat When every campaign scenario id is present in `completed_scenarios`, the battle scene shows a campaign completion panel. Starting a new campaign clears the automatic save file and resets `current_scenario_id` to `start_scenario`; the manual checkpoint is left alone until overwritten from the Save menu. -Run `tools/validate_data.ps1` after data edits to check campaign paths, chapter ranges, map dimensions, terrain keys, class/officer/item/skill references, skill area shapes, skill status/action-lock effect shapes, item effect names, cure statuses, debuff cleanse effects, officer and deployment portraits, officer and deployment equipment slot compatibility including accessory types and weapon effectiveness fields, promotion routes and promotion equipment compatibility, condition/event names, event `after_event` references, event trigger unit/officer filters, event item/gold grants, event withdrawal unit ids, event AI target-priority unit ids, condition unit references, destination `pos`/`cells` coordinates, campaign flag references, joined-officer gates, briefing line blocks, deployment ids, deployment bounds, impassable spawn cells, shop stock, roster rules, formation cells, post-battle dialogue, post-battle choices, and reward item ids. +Run `tools/validate_data.ps1` after data edits to check campaign paths, chapter ranges, map dimensions, terrain keys, class/officer/item/skill references, skill area shapes, skill status/action-lock effect shapes, item effect names, cure statuses, debuff cleanse effects, class sprites, item icons, map backgrounds, officer and deployment portraits/sprites, officer and deployment equipment slot compatibility including accessory types and weapon effectiveness fields, promotion routes and promotion equipment compatibility, condition/event names, event `after_event` references, event trigger unit/officer filters, event item/gold grants, event withdrawal unit ids, event AI target-priority unit ids, condition unit references, destination `pos`/`cells` coordinates, campaign flag references, joined-officer gates, briefing line blocks, camp dialogue, deployment ids, deployment bounds, impassable spawn cells, shop stock, shop merchant lines, roster rules, formation cells, post-battle dialogue, post-battle choices, and reward item ids. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index af905be..4c162bd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -46,6 +46,7 @@ - Mid-battle map/action events have a first action system with movement ambushes and scripted unit withdrawals. - Opening battle dialogue exists through event actions. - Post-battle dialogue exists before the victory result panel. +- Pre-battle camp dialogue can be launched from the briefing Talk button, and shops can show merchant flavor lines. - Reinforcements, scripted unit withdrawals, objective changes with HUD/log feedback, and item/gold pickup events exist in a basic form. - Post-battle reward save, campaign choice flags, first flag-driven briefing/shop/event branches, sectioned result summaries, and choice reward previews exist in a basic form. Richer reward screens and wider branch support are still planned. @@ -133,8 +134,9 @@ ## Milestone 5: Presentation -- Portrait pipeline. Officer definitions can provide default portrait paths, dialogue lines can override them, battle HUD thumbnails reuse them for selected or hovered units, missing art falls back to speaker initials, and the first seven AI-generated photorealistic officer portraits are in `art/portraits`. -- 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, poison, seal, snare, and disarm effects, with HUD summaries showing remaining phases. +- Portrait and art pipeline. Officer definitions can provide default portrait paths, classes can provide board `sprite` paths, items can provide `icon` paths, scenarios can provide map `background` paths, and the first generated officer portraits, unit sprites, item icons, and battlefield background are in `art/`. +- Unit sprites and animations. Board movement now has a short slide animation, attacks request a short strike animation, and board units show class sprite tokens, team rings, class badges, low-HP warning rings, and small status pips for active support, debuff, poison, seal, snare, and disarm effects, with HUD summaries showing remaining phases. +- Battlefield presentation. Maps can draw a high-resolution background under translucent terrain overlays, with first terrain-specific detail patterns for plains, forest, hill, road, water, and castle cells. - Battle effects. First floating combat text now covers damage, recovery, misses, support effects, poison ticks, item and gold pickups, AI target-focus changes, level-ups, and promotions. Hover target preview badges summarize attacks, tactics, and items, and the board can overlay enemy threat ranges with physical damage and hostile tactic hints. - Music and sound. Basic placeholder BGM and SFX now play for menus, battle flow, unit actions, tactics, items, and result states. - Chapter UI, objective presentation, and visual novel scenes. Pre-battle briefing now uses campaign chapter ranges for chapter-aware numbering, has a chapter progress/replay overview plus objective/progress/risk tracker, active battle HUD shows the same live objective panel, and dialogue lines can choose left or right portrait placement in an expanded portrait dialogue panel with progress and previous-line controls. diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index 7fc88ca..bd90184 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -8,6 +8,7 @@ signal log_added(message: String) signal dialogue_requested(lines: Array) signal combat_feedback_requested(unit_id: String, text: String, kind: String) signal unit_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i) +signal unit_action_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i, action_kind: String) signal objective_updated(victory: String, defeat: String) const TEAM_PLAYER := "player" @@ -93,6 +94,7 @@ var shop := {} var deployment_rules := {} var formation_cells: Array[Vector2i] = [] var map_size := Vector2i.ZERO +var map_background := "" var terrain_rows: Array[String] = [] var terrain_defs := DEFAULT_TERRAIN_DEFS.duplicate(true) var units: Array[Dictionary] = [] @@ -176,6 +178,7 @@ func _apply_battle_data(data: Dictionary, roster_overrides := {}) -> void: var map_data: Dictionary = data.get("map", {}) map_size = Vector2i(int(map_data.get("width", 0)), int(map_data.get("height", 0))) + map_background = str(map_data.get("background", "")) terrain_rows.clear() for row in map_data.get("terrain", []): terrain_rows.append(String(row)) @@ -253,7 +256,7 @@ func _normalized_shop(source) -> Dictionary: var seen := {} var stock := {} if typeof(source) != TYPE_DICTIONARY: - return {"items": item_ids, "stock": stock} + return {"items": item_ids, "stock": stock, "merchant": {}} for entry in source.get("items", []): var item_id := _shop_item_id_from_entry(entry) if item_id.is_empty() or seen.has(item_id): @@ -275,7 +278,24 @@ func _normalized_shop(source) -> Dictionary: item_ids.append(item_id) _apply_shop_stock_entry(stock, item_id, entry) _apply_shop_stock_block(stock, block.get("stock", {}), seen) - return {"items": item_ids, "stock": stock} + return {"items": item_ids, "stock": stock, "merchant": _normalized_shop_merchant(source.get("merchant", {}))} + + +func _normalized_shop_merchant(source) -> Dictionary: + if typeof(source) != TYPE_DICTIONARY: + return {} + var lines := [] + for line in source.get("lines", []): + var text := str(line).strip_edges() + if not text.is_empty(): + lines.append(text) + var name := str(source.get("name", "Merchant")).strip_edges() + if name.is_empty() and lines.is_empty(): + return {} + return { + "name": "Merchant" if name.is_empty() else name, + "lines": lines + } func _shop_item_id_from_entry(entry) -> String: @@ -1552,6 +1572,13 @@ func get_shop_stock_limit(item_id: String) -> int: return int(stock.get(normalized, -1)) +func get_shop_merchant() -> Dictionary: + var merchant = shop.get("merchant", {}) + if typeof(merchant) != TYPE_DICTIONARY: + return {} + return merchant.duplicate(true) + + func get_formation_cells() -> Array[Vector2i]: return formation_cells.duplicate() @@ -1842,6 +1869,10 @@ func get_terrain_key(cell: Vector2i) -> String: return row.substr(cell.x, 1) +func get_map_background_path() -> String: + return map_background + + func get_terrain_name(cell: Vector2i) -> String: return String(terrain_defs.get(get_terrain_key(cell), DEFAULT_TERRAIN_DEFS["G"]).get("name", "Plain")) @@ -3099,6 +3130,12 @@ func _resolve_combat(attacker: Dictionary, target: Dictionary) -> void: func _resolve_attack(attacker: Dictionary, target: Dictionary, is_counter := false) -> Dictionary: var hit_chance := calculate_hit_chance(attacker, target) var verb := "counterattacks" if is_counter else "attacks" + unit_action_motion_requested.emit( + str(attacker.get("id", "")), + attacker.get("pos", Vector2i(-1, -1)), + target.get("pos", Vector2i(-1, -1)), + "counter" if is_counter else "attack" + ) 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") diff --git a/scripts/core/data_catalog.gd b/scripts/core/data_catalog.gd index 62fb427..3c2e7f5 100644 --- a/scripts/core/data_catalog.gd +++ b/scripts/core/data_catalog.gd @@ -41,6 +41,7 @@ func hydrate_deployment(deployment: Dictionary) -> Dictionary: unit["officer_id"] = str(deployment.get("officer_id", "")) unit["name"] = str(deployment.get("name", officer.get("name", unit["id"]))) unit["portrait"] = str(deployment.get("portrait", officer.get("portrait", ""))) + unit["sprite"] = str(deployment.get("sprite", officer.get("sprite", class_def.get("sprite", "")))) unit["class_id"] = class_id unit["class"] = str(class_def.get("name", class_id.capitalize())) unit["team"] = str(deployment.get("team", "enemy")) diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 916f58a..b108b23 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -6,6 +6,7 @@ const CampaignStateScript := preload("res://scripts/core/campaign_state.gd") const CAMPAIGN_PATH := "res://data/campaign/campaign.json" const TILE_SIZE := 64 const BOARD_OFFSET := Vector2(48, 104) +const DEFAULT_BATTLE_BACKGROUND_PATH := "res://art/backgrounds/battlefield_frontier.png" const PLAYER_COLOR := Color(0.20, 0.42, 0.82) const ENEMY_COLOR := Color(0.72, 0.18, 0.16) const GRID_COLOR := Color(0.05, 0.06, 0.07, 0.45) @@ -44,6 +45,8 @@ 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 +const UNIT_ATTACK_ANIMATION_DURATION := 0.30 +const UNIT_ATTACK_LUNGE_DISTANCE := 15.0 const DIALOGUE_PANEL_POSITION := Vector2(96, 448) const DIALOGUE_PANEL_SIZE := Vector2(1040, 210) const DIALOGUE_PORTRAIT_SIZE := Vector2(164, 186) @@ -94,6 +97,7 @@ var chapter_menu: VBoxContainer var chapter_list: VBoxContainer var chapter_status_label: Label var shop_button: Button +var talk_button: Button var shop_menu: VBoxContainer var shop_buy_button: Button var shop_sell_button: Button @@ -162,6 +166,9 @@ var last_announced_battle_status := "" 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_background_path := "" +var battle_background_texture: Texture2D func _ready() -> void: @@ -172,6 +179,7 @@ func _ready() -> void: state.dialogue_requested.connect(_on_dialogue_requested) state.combat_feedback_requested.connect(_on_combat_feedback_requested) state.unit_motion_requested.connect(_on_unit_motion_requested) + state.unit_action_motion_requested.connect(_on_unit_action_motion_requested) state.objective_updated.connect(_on_objective_updated) campaign_state.load_campaign(CAMPAIGN_PATH) campaign_state.load_or_start(campaign_state.get_start_scenario_id()) @@ -212,6 +220,7 @@ func _draw() -> void: _draw_map() _draw_overlays() _draw_units() + _draw_attack_effects() _draw_target_preview_badge() _draw_floating_texts() @@ -466,6 +475,11 @@ func _create_hud() -> void: shop_button.pressed.connect(_on_shop_pressed) prep_button_row.add_child(shop_button) + talk_button = Button.new() + talk_button.text = "Talk" + talk_button.pressed.connect(_on_talk_pressed) + prep_button_row.add_child(talk_button) + armory_button = Button.new() armory_button.text = "Armory" armory_button.pressed.connect(_on_armory_pressed) @@ -1023,6 +1037,17 @@ func _process(delta: float) -> void: unit_motion_by_unit = active_motion needs_redraw = true + if not unit_action_motion_by_unit.is_empty(): + var active_actions := {} + for unit_id in unit_action_motion_by_unit.keys(): + 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 + 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 + needs_redraw = true + if needs_redraw: queue_redraw() @@ -1038,17 +1063,100 @@ 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 background := _current_battle_background_texture() + if background != null: + _draw_texture_cover(background, board_rect, Color(1.0, 1.0, 1.0, 0.96)) + draw_rect(board_rect, Color(0.02, 0.025, 0.03, 0.12)) + else: + draw_rect(board_rect, Color(0.18, 0.26, 0.18)) + for y in range(state.map_size.y): for x in range(state.map_size.x): var cell := Vector2i(x, y) var rect := _rect_for_cell(cell) - draw_rect(rect, state.get_terrain_color(cell)) + var terrain_key := state.get_terrain_key(cell) + var terrain_color := state.get_terrain_color(cell) + terrain_color.a = 0.26 + draw_rect(rect, terrain_color) + _draw_terrain_detail(cell, rect, terrain_key) draw_rect(rect, GRID_COLOR, false, 1.0) - var board_rect := Rect2(BOARD_OFFSET, Vector2(state.map_size.x, state.map_size.y) * TILE_SIZE) draw_rect(board_rect, Color(0.02, 0.025, 0.03), false, 3.0) +func _current_battle_background_texture() -> Texture2D: + var next_path := state.get_map_background_path() + if next_path.is_empty(): + next_path = DEFAULT_BATTLE_BACKGROUND_PATH + if next_path == battle_background_path: + return battle_background_texture + battle_background_path = next_path + battle_background_texture = _load_art_texture(next_path) + return battle_background_texture + + +func _draw_texture_cover(texture: Texture2D, rect: Rect2, modulate: Color) -> void: + var texture_size := texture.get_size() + if texture_size.x <= 0.0 or texture_size.y <= 0.0 or rect.size.x <= 0.0 or rect.size.y <= 0.0: + return + var target_ratio := rect.size.x / rect.size.y + var source_ratio := texture_size.x / texture_size.y + var source_rect := Rect2(Vector2.ZERO, texture_size) + if source_ratio > target_ratio: + var source_width := texture_size.y * target_ratio + source_rect.position.x = (texture_size.x - source_width) * 0.5 + source_rect.size.x = source_width + else: + var source_height := texture_size.x / target_ratio + source_rect.position.y = (texture_size.y - source_height) * 0.5 + source_rect.size.y = source_height + draw_texture_rect_region(texture, rect, source_rect, modulate) + + +func _draw_terrain_detail(cell: Vector2i, rect: Rect2, terrain_key: String) -> void: + if terrain_key == "F": + for index in range(4): + var point := _terrain_detail_point(cell, rect, index) + draw_circle(point, 4.5 + _cell_noise(cell, index + 11) * 2.0, Color(0.05, 0.20, 0.09, 0.42)) + draw_line(point + Vector2(0, 4), point + Vector2(0, 9), Color(0.08, 0.11, 0.06, 0.34), 1.3) + elif terrain_key == "H": + for index in range(3): + var point := _terrain_detail_point(cell, rect, index + 8) + var peak := point + Vector2(0, -9) + draw_line(point + Vector2(-10, 8), peak, Color(0.88, 0.84, 0.72, 0.38), 2.0) + draw_line(peak, point + Vector2(10, 8), Color(0.22, 0.20, 0.16, 0.34), 2.0) + elif terrain_key == "R": + var road_color := Color(0.82, 0.69, 0.48, 0.34) + draw_rect(Rect2(rect.position + Vector2(rect.size.x * 0.36, 0), Vector2(rect.size.x * 0.28, rect.size.y)), road_color) + draw_line(rect.position + Vector2(rect.size.x * 0.48, 6), rect.position + Vector2(rect.size.x * 0.52, rect.size.y - 6), Color(0.36, 0.28, 0.18, 0.24), 1.5) + elif terrain_key == "W": + for index in range(3): + var y := rect.position.y + 15.0 + float(index) * 14.0 + draw_line(rect.position + Vector2(10, y - rect.position.y), rect.position + Vector2(rect.size.x - 10, y - rect.position.y + 4), Color(0.70, 0.88, 1.0, 0.32), 1.6) + elif terrain_key == "C": + draw_rect(rect.grow(-7.0), Color(0.16, 0.15, 0.14, 0.28), false, 2.0) + draw_line(rect.position + Vector2(10, 21), rect.position + Vector2(rect.size.x - 10, 21), Color(0.84, 0.80, 0.72, 0.30), 1.4) + draw_line(rect.position + Vector2(10, 39), rect.position + Vector2(rect.size.x - 10, 39), Color(0.84, 0.80, 0.72, 0.22), 1.2) + draw_line(rect.position + Vector2(23, 11), rect.position + Vector2(23, rect.size.y - 11), Color(0.05, 0.05, 0.05, 0.20), 1.2) + draw_line(rect.position + Vector2(43, 11), rect.position + Vector2(43, rect.size.y - 11), Color(0.05, 0.05, 0.05, 0.20), 1.2) + else: + for index in range(2): + var point := _terrain_detail_point(cell, rect, index + 15) + draw_line(point, point + Vector2(3, -7), Color(0.78, 0.88, 0.50, 0.26), 1.2) + + +func _terrain_detail_point(cell: Vector2i, rect: Rect2, salt: int) -> Vector2: + return rect.position + Vector2( + 10.0 + _cell_noise(cell, salt) * (rect.size.x - 20.0), + 10.0 + _cell_noise(cell, salt + 37) * (rect.size.y - 20.0) + ) + + +func _cell_noise(cell: Vector2i, salt: int) -> float: + return fposmod(sin(float(cell.x * 91 + cell.y * 193 + salt * 53)) * 43758.5453, 1.0) + + func _draw_overlays() -> void: for cell in state.get_objective_cells(): var objective_rect := _rect_for_cell(cell) @@ -1108,16 +1216,29 @@ 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 body_color := team_color.darkened(0.35) if unit.get("acted", false) else team_color + var token_back := Color(0.02, 0.022, 0.026, 0.78) + var acted := bool(unit.get("acted", false)) + var sprite_modulate := Color(0.60, 0.62, 0.66, 0.82) if acted else Color.WHITE - draw_circle(center, 24, body_color) - draw_arc(center, 24, 0.0, TAU, 48, Color(0.03, 0.035, 0.04), 2.0) + 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_class_badge(rect, unit, team_color) var label := _short_name(String(unit["name"])) - draw_string(font, rect.position + Vector2(2, 25), label, HORIZONTAL_ALIGNMENT_CENTER, TILE_SIZE - 4, 13, Color.WHITE) + draw_string(font, rect.position + Vector2(2, TILE_SIZE - 18), label, HORIZONTAL_ALIGNMENT_CENTER, TILE_SIZE - 4, 11, Color.WHITE) var hp_ratio := float(unit["hp"]) / float(max(1, int(unit["max_hp"]))) - var hp_back := Rect2(rect.position + Vector2(8, TILE_SIZE - 12), Vector2(TILE_SIZE - 16, 6)) + var hp_back := Rect2(rect.position + Vector2(8, TILE_SIZE - 8), Vector2(TILE_SIZE - 16, 5)) 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)) @@ -1127,6 +1248,36 @@ func _draw_units() -> void: draw_arc(center, 29, 0.0, TAU, 48, Color(1.0, 0.92, 0.25), 3.0) +func _fit_texture_rect(texture: Texture2D, target: Rect2) -> Rect2: + var texture_size := texture.get_size() + if texture_size.x <= 0.0 or texture_size.y <= 0.0: + return target + var scale := minf(target.size.x / texture_size.x, target.size.y / texture_size.y) + var fit_size := texture_size * scale + return Rect2(target.position + (target.size - fit_size) * 0.5, fit_size) + + +func _draw_unit_class_badge(rect: Rect2, unit: Dictionary, team_color: Color) -> void: + var badge_rect := Rect2(rect.position + Vector2(5, 5), Vector2(28, 13)) + draw_rect(badge_rect, Color(0.02, 0.022, 0.026, 0.82)) + draw_rect(badge_rect, team_color.lightened(0.18), false, 1.2) + var font := ThemeDB.fallback_font + draw_string(font, badge_rect.position + Vector2(1, 11), _unit_class_abbrev(unit), HORIZONTAL_ALIGNMENT_CENTER, badge_rect.size.x - 2, 8, Color.WHITE) + + +func _unit_class_abbrev(unit: Dictionary) -> String: + var class_id := str(unit.get("class_id", "")) + if class_id.contains("cavalry"): + return "CAV" + if class_id.contains("archer") or class_id.contains("marksman"): + return "ARC" + if class_id.contains("strategist") or class_id.contains("advisor") or class_id.contains("hero") or class_id.contains("commander"): + return "TAC" + if class_id.contains("warrior") or class_id.contains("champion") or class_id.contains("bandit"): + return "WAR" + return "INF" + + func _unit_hp_color(hp_ratio: float) -> Color: if hp_ratio <= LOW_HP_WARNING_RATIO: return Color(1.0, 0.24, 0.18) @@ -1208,22 +1359,75 @@ 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): + if unit_id.is_empty(): 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: float = maxf(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) + if unit_motion_by_unit.has(unit_id): + var move_motion: Dictionary = unit_motion_by_unit[unit_id] + var move_from_cell: Vector2i = move_motion.get("from", cell) + var move_to_cell: Vector2i = move_motion.get("to", cell) + var move_duration: float = maxf(0.01, float(move_motion.get("duration", UNIT_MOVE_ANIMATION_DURATION))) + var move_age := float(move_motion.get("age", 0.0)) + var move_progress := clampf(move_age / move_duration, 0.0, 1.0) + var smooth_progress := move_progress * move_progress * (3.0 - 2.0 * move_progress) + var from_rect := _rect_for_cell(move_from_cell) + var to_rect := _rect_for_cell(move_to_cell) + rect.position = from_rect.position.lerp(to_rect.position, smooth_progress) + + if unit_action_motion_by_unit.has(unit_id): + var action_motion: Dictionary = unit_action_motion_by_unit[unit_id] + var action_from_cell: Vector2i = action_motion.get("from", cell) + var action_to_cell: Vector2i = action_motion.get("to", cell) + var direction := _cell_center(action_to_cell) - _cell_center(action_from_cell) + if direction.length() > 0.01: + direction = direction.normalized() + var action_duration: float = maxf(0.01, float(action_motion.get("duration", UNIT_ATTACK_ANIMATION_DURATION))) + var action_age := float(action_motion.get("age", 0.0)) + var action_progress := clampf(action_age / action_duration, 0.0, 1.0) + var lunge_scale := 0.35 if str(action_motion.get("style", "melee")) == "ranged" else 1.0 + rect.position += direction * sin(action_progress * PI) * UNIT_ATTACK_LUNGE_DISTANCE * lunge_scale return rect +func _cell_center(cell: Vector2i) -> Vector2: + var rect := _rect_for_cell(cell) + return rect.position + rect.size * 0.5 + + +func _draw_attack_effects() -> void: + if unit_action_motion_by_unit.is_empty(): + return + for unit_id in unit_action_motion_by_unit.keys(): + 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)) + var progress := clampf(age / duration, 0.0, 1.0) + var peak := sin(progress * PI) + if peak <= 0.0: + continue + var from_cell: Vector2i = motion.get("from", Vector2i(-1, -1)) + var to_cell: Vector2i = motion.get("to", Vector2i(-1, -1)) + if not state.is_inside(from_cell) or not state.is_inside(to_cell): + continue + var start := _cell_center(from_cell) + var end := _cell_center(to_cell) + var team_color := PLAYER_COLOR + var unit := state.get_unit(str(unit_id)) + if not unit.is_empty() and unit.get("team", "") == BattleState.TEAM_ENEMY: + team_color = ENEMY_COLOR + var flash := team_color.lightened(0.45) + flash.a = 0.55 * peak + if str(motion.get("style", "melee")) == "ranged": + var head := start.lerp(end, clampf(progress + 0.18, 0.0, 1.0)) + var tail := start.lerp(end, clampf(progress - 0.18, 0.0, 1.0)) + draw_line(tail, head, flash, 3.0) + draw_circle(head, 4.0 + 4.0 * peak, Color(1.0, 0.92, 0.52, 0.42 * peak)) + else: + draw_line(end + Vector2(-18, -13), end + Vector2(18, 13), flash, 3.0) + draw_line(end + Vector2(16, -14), end + Vector2(-14, 15), Color(1.0, 0.88, 0.42, 0.38 * peak), 2.0) + draw_circle(end, 8.0 + 8.0 * peak, Color(1.0, 0.82, 0.34, 0.20 * peak)) + + func _draw_floating_texts() -> void: if floating_texts.is_empty(): return @@ -1627,6 +1831,8 @@ func _update_hud_unit_portrait(unit: Dictionary) -> void: return hud_unit_portrait_panel.visible = true var texture := _load_portrait_texture(str(unit.get("portrait", ""))) + if texture == null: + texture = _load_art_texture(str(unit.get("sprite", ""))) var has_portrait := texture != null hud_unit_portrait_texture.texture = texture hud_unit_portrait_texture.visible = has_portrait @@ -2099,6 +2305,23 @@ func _on_unit_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Ve queue_redraw() +func _on_unit_action_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i, action_kind: String) -> void: + if unit_id.is_empty() or from_cell == to_cell: + return + var unit := state.get_unit(unit_id) + var distance := absi(from_cell.x - to_cell.x) + absi(from_cell.y - to_cell.y) + var style := "ranged" if int(unit.get("range", 1)) > 1 and distance > 1 else "melee" + unit_action_motion_by_unit[unit_id] = { + "from": from_cell, + "to": to_cell, + "kind": action_kind, + "style": style, + "age": 0.0, + "duration": UNIT_ATTACK_ANIMATION_DURATION + } + 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) @@ -2208,6 +2431,10 @@ func _update_dialogue_portrait(speaker: String, portrait_path: String) -> void: dialogue_portrait_label.visible = not has_portrait and has_placeholder +func _load_art_texture(texture_path: String) -> Texture2D: + return _load_portrait_texture(texture_path) + + func _load_portrait_texture(portrait_path: String) -> Texture2D: var normalized_path := portrait_path.strip_edges() if normalized_path.is_empty(): @@ -2594,6 +2821,8 @@ func _show_briefing() -> void: chapter_button.disabled = campaign_state.get_chapter_progress_entries().is_empty() if shop_button != null: shop_button.disabled = prep_locked or (state.get_shop_item_ids().is_empty() and _get_sellable_item_ids().is_empty()) + if talk_button != null: + talk_button.disabled = prep_locked or _camp_dialogue_lines().is_empty() if armory_button != null: armory_button.disabled = prep_locked or state.get_controllable_player_units().is_empty() if roster_button != null: @@ -2755,6 +2984,42 @@ func _hide_shop_menu() -> void: shop_menu.visible = false +func _on_talk_pressed() -> void: + if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible: + return + var lines := _camp_dialogue_lines() + if lines.is_empty(): + _play_ui_cancel() + return + _play_ui_confirm() + _hide_chapter_menu() + _hide_shop_menu() + _hide_armory_menu() + _hide_roster_menu() + _hide_formation_menu() + _hide_save_menu() + _show_camp_dialogue(lines) + _update_hud() + + +func _camp_dialogue_lines() -> Array: + var briefing := state.get_briefing() + var lines = briefing.get("camp_dialogue", []) + if typeof(lines) != TYPE_ARRAY: + return [] + return lines + + +func _show_camp_dialogue(lines: Array) -> void: + var normalized_lines := _normalized_dialogue_lines(lines) + if normalized_lines.is_empty(): + return + dialogue_queue.clear() + active_dialogue_lines = normalized_lines + active_dialogue_index = 0 + _render_dialogue_line() + + func _on_armory_pressed() -> void: if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible: return @@ -3260,6 +3525,9 @@ func _rebuild_shop_menu() -> void: if shop_sell_button != null: shop_sell_button.disabled = shop_sell_mode or _get_sellable_item_ids().is_empty() + if not shop_sell_mode: + _add_shop_merchant_notice() + if shop_sell_mode: _rebuild_shop_sell_list() else: @@ -3285,6 +3553,7 @@ func _rebuild_shop_buy_list() -> void: var remaining := stock_limit - purchased if stock_limit >= 0 else -1 var buy_button := Button.new() buy_button.text = _format_shop_item_button_text(normalized_id, item, price, owned, stock_limit, remaining) + _apply_item_button_icon(buy_button, item) buy_button.disabled = campaign_state.gold < price or (stock_limit >= 0 and remaining <= 0) buy_button.pressed.connect(_on_shop_item_pressed.bind(normalized_id)) shop_list.add_child(buy_button) @@ -3306,6 +3575,7 @@ func _rebuild_shop_sell_list() -> void: var owned := int(inventory.get(normalized_id, 0)) var sell_button := Button.new() sell_button.text = _format_shop_sell_item_button_text(normalized_id, item, sale_price, owned) + _apply_item_button_icon(sell_button, item) sell_button.pressed.connect(_on_shop_sell_item_pressed.bind(normalized_id)) shop_list.add_child(sell_button) @@ -3316,6 +3586,27 @@ func _clear_shop_list() -> void: child.queue_free() +func _add_shop_merchant_notice() -> void: + var merchant := state.get_shop_merchant() + if merchant.is_empty(): + return + var lines := [] + for line in merchant.get("lines", []): + var text := str(line).strip_edges() + if not text.is_empty(): + lines.append(text) + if lines.is_empty(): + return + var merchant_label := Label.new() + merchant_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + merchant_label.custom_minimum_size = Vector2(560, 0) + merchant_label.text = "%s: %s" % [ + str(merchant.get("name", "Merchant")), + _join_strings(lines, " ") + ] + shop_list.add_child(merchant_label) + + func _format_shop_item_button_text(item_id: String, item: Dictionary, price: int, owned: int, stock_limit: int = -1, remaining: int = -1) -> String: var unavailable := "" if stock_limit >= 0 and remaining <= 0: @@ -3362,6 +3653,15 @@ func _format_shop_item_effect_text(item: Dictionary) -> String: return kind.capitalize() +func _apply_item_button_icon(button: Button, item: Dictionary) -> void: + var icon := _load_art_texture(str(item.get("icon", ""))) + if icon == null: + return + button.icon = icon + button.expand_icon = true + button.icon_max_width = 30 + + func _on_shop_item_pressed(item_id: String) -> void: if battle_started or _is_prebattle_prep_locked(): return @@ -4089,6 +4389,7 @@ func _rebuild_item_menu(selected: Dictionary) -> void: var count := int(inventory.get(normalized_id, 0)) var use_button := Button.new() use_button.text = _format_item_button_text(normalized_id, item, count) + _apply_item_button_icon(use_button, item) use_button.disabled = selected.get("acted", false) or _is_input_locked() or count <= 0 use_button.pressed.connect(_on_item_selected_pressed.bind(normalized_id)) item_list.add_child(use_button) @@ -4201,6 +4502,7 @@ func _rebuild_equip_menu(selected: Dictionary) -> void: var item := state.get_item_def(item_id) var unequip_button := Button.new() unequip_button.text = _format_equipment_unequip_text(selected, normalized_slot, item_id, item) + _apply_item_button_icon(unequip_button, item) unequip_button.disabled = _is_input_locked() unequip_button.pressed.connect(_on_equip_unequip_pressed.bind(normalized_slot)) equip_list.add_child(unequip_button) @@ -4222,6 +4524,7 @@ func _rebuild_equip_menu(selected: Dictionary) -> void: item, int(inventory.get(normalized_id, 0)) ) + _apply_item_button_icon(equip_item_button, item) equip_item_button.disabled = _is_input_locked() equip_item_button.pressed.connect(_on_equip_item_pressed.bind(normalized_id)) equip_list.add_child(equip_item_button) diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd new file mode 100644 index 0000000..35eb1e6 --- /dev/null +++ b/tools/smoke_visual_assets.gd @@ -0,0 +1,113 @@ +extends SceneTree + +const BattleStateScript := preload("res://scripts/core/battle_state.gd") +const BattleSceneScript := preload("res://scripts/scenes/battle_scene.gd") + + +func _init() -> void: + var failures: Array[String] = [] + _check_battle_visual_data(failures) + _check_scene_texture_loading(failures) + _check_attack_motion_signal(failures) + + if failures.is_empty(): + print("visual assets smoke ok") + quit(0) + return + + for failure in failures: + push_error(failure) + quit(1) + + +func _check_battle_visual_data(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load opening battle") + return + + var briefing := state.get_briefing() + if (briefing.get("camp_dialogue", []) as Array).is_empty(): + failures.append("001 briefing should expose camp dialogue") + var merchant := state.get_shop_merchant() + if merchant.is_empty() or (merchant.get("lines", []) as Array).is_empty(): + failures.append("001 shop should expose merchant lines") + + _check_image_path(failures, state.get_map_background_path(), "001 map background") + for unit_id in ["cao_cao", "xiahou_dun", "yellow_turban_1", "yellow_turban_2", "yellow_turban_3"]: + var unit := state.get_unit(unit_id) + if unit.is_empty(): + failures.append("missing expected unit: %s" % unit_id) + continue + _check_image_path(failures, str(unit.get("sprite", "")), "unit %s sprite" % unit_id) + + for item_id in ["bronze_sword", "training_spear", "short_bow", "hand_axe", "iron_armor", "panacea"]: + var item := state.get_item_def(item_id) + if item.is_empty(): + failures.append("missing expected item: %s" % item_id) + continue + _check_image_path(failures, str(item.get("icon", "")), "item %s icon" % item_id) + + +func _check_scene_texture_loading(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 battle scene state") + scene.free() + return + if scene._current_battle_background_texture() == null: + failures.append("battle scene should load a battlefield background texture") + var archer: Dictionary = scene.state.get_unit("yellow_turban_3") + if scene._load_art_texture(str(archer.get("sprite", ""))) == null: + failures.append("battle scene should load archer sprite texture") + var panacea: Dictionary = scene.state.get_item_def("panacea") + if scene._load_art_texture(str(panacea.get("icon", ""))) == null: + failures.append("battle scene should load panacea icon texture") + scene.free() + + +func _check_attack_motion_signal(failures: Array[String]) -> void: + var state = BattleStateScript.new() + if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): + failures.append("could not load battle for attack motion") + return + + var attacker := state.get_unit("cao_cao") + var target := state.get_unit("yellow_turban_1") + if attacker.is_empty() or target.is_empty(): + failures.append("missing attacker or target for attack motion") + return + attacker["pos"] = Vector2i(6, 2) + target["pos"] = Vector2i(7, 2) + + var motions: Array[String] = [] + state.unit_action_motion_requested.connect(func(unit_id: String, from_cell: Vector2i, to_cell: Vector2i, action_kind: String) -> void: + motions.append("%s|%s|%d,%d|%d,%d" % [unit_id, action_kind, from_cell.x, from_cell.y, to_cell.x, to_cell.y]) + ) + if not state.select_unit("cao_cao"): + failures.append("could not select Cao Cao for attack motion") + return + if not state.try_attack_selected("yellow_turban_1"): + failures.append("Cao Cao should be able to attack adjacent target") + return + if not motions.has("cao_cao|attack|6,2|7,2"): + failures.append("attack motion signal missing expected Cao Cao attack: %s" % str(motions)) + + +func _check_image_path(failures: Array[String], path: String, context: String) -> void: + if path.is_empty(): + failures.append("%s has an empty image path" % context) + return + if not path.begins_with("res://"): + failures.append("%s must use a res:// path: %s" % [context, path]) + return + if not FileAccess.file_exists(path): + failures.append("%s references missing image: %s" % [context, path]) + return + var image := Image.new() + var err := image.load(path) + if err != OK: + failures.append("%s references unreadable image: %s" % [context, path]) + return + if image.get_width() < 512 or image.get_height() < 512: + failures.append("%s image should be at least 512x512: %s (%dx%d)" % [context, path, image.get_width(), image.get_height()]) diff --git a/tools/validate_data.ps1 b/tools/validate_data.ps1 index ac07593..8d950a0 100644 --- a/tools/validate_data.ps1 +++ b/tools/validate_data.ps1 @@ -500,6 +500,9 @@ function Check-Deployment($Deployment, [string]$ScenarioId, [int]$Width, [int]$H if (Has-Prop $Deployment "portrait") { Check-Portrait-Path (Get-Prop $Deployment "portrait" "") "Scenario $ScenarioId deployment $unitId portrait" } + if (Has-Prop $Deployment "sprite") { + Check-Portrait-Path (Get-Prop $Deployment "sprite" "") "Scenario $ScenarioId deployment $unitId sprite" + } if (Has-Prop $Deployment "requires_joined") { if (-not ((Get-Prop $Deployment "requires_joined" $false) -is [bool])) { Fail "Scenario $ScenarioId deployment $unitId requires_joined must be boolean." @@ -871,6 +874,9 @@ function Check-Item-Effects() { $itemId = $itemProp.Name $item = $itemProp.Value $kind = [string](Get-Prop $item "kind" "") + if (Has-Prop $item "icon") { + Check-Portrait-Path (Get-Prop $item "icon" "") "Item $itemId icon" + } if (-not $validItemKinds.Contains($kind)) { Fail "Item $itemId has unknown kind: $kind" } @@ -1197,6 +1203,25 @@ function Check-Shop($Shop, [string]$ScenarioId) { if (Has-Prop $Shop "stock") { Check-Shop-Stock-Block $Shop.stock $seenShopItems "Scenario $ScenarioId shop" } + if (Has-Prop $Shop "merchant") { + $merchant = Get-Prop $Shop "merchant" $null + if ($null -eq $merchant -or $merchant -is [string] -or $merchant -is [System.Array]) { + Fail "Scenario $ScenarioId shop merchant must be an object." + } + $merchantName = [string](Get-Prop $merchant "name" "Merchant") + if ([string]::IsNullOrWhiteSpace($merchantName)) { + Fail "Scenario $ScenarioId shop merchant has an empty name." + } + $merchantLines = Get-Prop $merchant "lines" $null + if ($null -eq $merchantLines -or -not ($merchantLines -is [System.Array])) { + Fail "Scenario $ScenarioId shop merchant must have line array." + } + foreach ($merchantLine in @($merchantLines)) { + if ([string]::IsNullOrWhiteSpace([string]$merchantLine)) { + Fail "Scenario $ScenarioId shop merchant has an empty line." + } + } + } $conditionalItems = Get-Prop $Shop "conditional_items" @() foreach ($block in @($conditionalItems)) { @@ -1670,6 +1695,9 @@ function Check-Briefing($Briefing, [string]$ScenarioId) { Fail "Scenario $ScenarioId briefing has an empty line." } } + foreach ($line in @((Get-Prop $Briefing "camp_dialogue" @()))) { + Check-Dialogue-Line $line "Scenario $ScenarioId briefing camp_dialogue" + } $conditionalLines = Get-Prop $Briefing "conditional_lines" @() foreach ($block in @($conditionalLines)) { @@ -1814,6 +1842,9 @@ Check-Class-Promotions foreach ($classProp in $classes.PSObject.Properties) { Check-Skill-Refs (Get-Prop $classProp.Value "skills" @()) "Class $($classProp.Name)" + if (Has-Prop $classProp.Value "sprite") { + Check-Portrait-Path (Get-Prop $classProp.Value "sprite" "") "Class $($classProp.Name) sprite" + } } $officerNames = New-Object System.Collections.Generic.HashSet[string] @@ -1842,6 +1873,9 @@ foreach ($scenario in $campaign.scenarios) { } $map = $scenarioData.map + if (Has-Prop $map "background") { + Check-Portrait-Path (Get-Prop $map "background" "") "Scenario $scenarioId map background" + } $width = [int]$map.width $height = [int]$map.height $rows = @($map.terrain)