diff --git a/README.md b/README.md index cbb1b4a..4341e02 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr - Min/max attack ranges and counterattack forecasts. - MP tactics with low/high damage options, area damage/healing/status options, ally support buffs, enemy debuffs, movement and accuracy/evasion modifiers, poison damage-over-time, seal/snare/disarm action locks, a skill list menu, previews, and target overlays. - Campaign inventory consumables with an item menu, HP/MP recovery, poison/seal/snare/disarm cure previews, and target overlay. -- Weapon, armor, and accessory rewards can be stored, displayed, and equipped from the battle HUD. +- Weapon, armor, and accessory rewards can be stored, displayed, and equipped from the battle HUD; some weapons deal bonus physical damage against matching move types. - 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 Armory equipment changes save immediately and sync roster equipment plus inventory stock. - Pre-battle Roster can move optional officers between sortie and reserve within scenario limits. diff --git a/data/defs/items.json b/data/defs/items.json index 1a205c8..5166cb0 100644 --- a/data/defs/items.json +++ b/data/defs/items.json @@ -23,6 +23,8 @@ "weapon_type": "spear", "range": [1, 1], "bonuses": { "atk": 4 }, + "effective_vs_move_types": ["mounted"], + "effective_bonus_damage": 3, "effects": [], "price": 360 }, @@ -32,6 +34,8 @@ "weapon_type": "bow", "range": [2, 2], "bonuses": { "atk": 3 }, + "effective_vs_move_types": ["water"], + "effective_bonus_damage": 2, "effects": [], "price": 320 }, @@ -41,6 +45,8 @@ "weapon_type": "axe", "range": [1, 1], "bonuses": { "atk": 4 }, + "effective_vs_move_types": ["foot"], + "effective_bonus_damage": 2, "effects": [], "price": 340 }, @@ -50,6 +56,8 @@ "weapon_type": "axe", "range": [1, 1], "bonuses": { "atk": 6 }, + "effective_vs_move_types": ["foot"], + "effective_bonus_damage": 3, "effects": [], "price": 680 }, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2b2dbfc..1ef90f40 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,7 +38,7 @@ 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. 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; 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, 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, low-HP warning rings, hover target preview badges, area tactic overlays, an enemy threat overlay, and floating combat text for core action and UI feedback. 8. Player ends turn. 9. Enemy AI moves, attacks, 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. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 62e27dc..49d29d6 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -318,7 +318,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. 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. +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. Consumables use `kind: "consumable"` with effect records. `heal_hp` restores HP, `heal_mp` restores MP, and `cure_status` removes active battle-only effects with a matching `status` from a valid target; `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. @@ -560,4 +560,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 and cure statuses, equipment slot compatibility including accessory types, promotion routes and promotion equipment compatibility, condition/event names, event item grants, condition unit references, destination condition 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 and cure statuses, equipment slot compatibility including accessory types and weapon effectiveness fields, promotion routes and promotion equipment compatibility, condition/event names, event item grants, condition unit references, destination condition 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. diff --git a/docs/GODOT_4_6_MIGRATION.md b/docs/GODOT_4_6_MIGRATION.md index 6fa4bb8..e6db763 100644 --- a/docs/GODOT_4_6_MIGRATION.md +++ b/docs/GODOT_4_6_MIGRATION.md @@ -45,7 +45,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools\check_godot46_readines - Chapters, Shop, Armory, Roster, Formation, and Save open and close; completed or current Chapter entries can open a battle briefing. - Finite-stock Shop entries show remaining counts and disable once sold out. - 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. +- A player unit can move, attack, wait, use a tactic, use an item, and equip compatible gear; weapon effectiveness appears in forecasts and equipment/shop text when applicable. - Movement-triggered supply cache events grant battle inventory items with pickup feedback, and the items persist only after victory. - The Threat toggle or `Y` key shows enemy physical and hostile tactic reach, and hovered threatened tiles name the threatening units. - Area tactics such as Blaze, Great Mend, and Poison Mist highlight affected cells, apply only to valid targets in the area, and spend MP once per cast. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 16bc77a..c0eafec 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -30,7 +30,7 @@ - Unit classes and class growth tables. Basic version exists. - Automatic class promotion routes. Basic level-threshold promotion exists for core first-tier battle classes. -- Weapon, armor, and accessory slots. Basic stat bonuses exist. +- Weapon, armor, and accessory slots. Basic stat bonuses and first weapon-vs-move-type physical damage bonuses exist. - Equipment rewards can be stored, displayed, and swapped from the battle HUD. - Terrain movement, defense, and avoid modifiers affect combat. - Skills, tactics, and MP. Multiple damage/heal tactics, first area damage/healing/status tactics, ally support buffs, enemy debuffs, movement and accuracy/evasion modifiers, poison damage-over-time, seal/snare/disarm action locks, a skill list menu with range/effect hints, and enemy tactic AI exist. diff --git a/scripts/core/battle_state.gd b/scripts/core/battle_state.gd index bc7df3a..910f5b3 100644 --- a/scripts/core/battle_state.gd +++ b/scripts/core/battle_state.gd @@ -708,16 +708,19 @@ func get_damage_preview(attacker_id: String, target_id: String) -> Dictionary: return {} var attack_locked := _unit_has_action_lock(attacker, "attack") + var effective_bonus := 0 if attack_locked else _weapon_effectiveness_bonus(attacker, target) var damage := 0 if attack_locked else calculate_damage(attacker, target) var target_hp_after: int = maxi(0, int(target["hp"]) - damage) var counter_damage := 0 var attacker_hp_after := int(attacker["hp"]) var hit_chance := 0 if attack_locked else calculate_hit_chance(attacker, target) var counter_hit_chance := 0 + var counter_effective_bonus := 0 var counter_in_range := false if not attack_locked and (target_hp_after > 0 or hit_chance < 100): counter_in_range = _is_in_attack_range(target, attacker["pos"]) if counter_in_range: + counter_effective_bonus = _weapon_effectiveness_bonus(target, attacker) counter_damage = calculate_damage(target, attacker) counter_hit_chance = calculate_hit_chance(target, attacker) attacker_hp_after = maxi(0, int(attacker["hp"]) - counter_damage) @@ -725,6 +728,8 @@ func get_damage_preview(attacker_id: String, target_id: String) -> Dictionary: "attacker_id": attacker_id, "target_id": target_id, "damage": damage, + "effective_bonus": effective_bonus, + "effective_target_type": _weapon_effectiveness_target_type(target) if effective_bonus > 0 else "", "hit_chance": hit_chance, "target_hp_after": target_hp_after, "would_defeat": int(target["hp"]) - damage <= 0, @@ -732,6 +737,8 @@ func get_damage_preview(attacker_id: String, target_id: String) -> Dictionary: "attack_locked": attack_locked, "counter_in_range": counter_in_range, "counter_damage": counter_damage, + "counter_effective_bonus": counter_effective_bonus, + "counter_effective_target_type": _weapon_effectiveness_target_type(attacker) if counter_effective_bonus > 0 else "", "counter_hit_chance": counter_hit_chance, "attacker_hp_after": attacker_hp_after, "counter_would_defeat": attacker_hp_after <= 0 @@ -828,7 +835,8 @@ func get_item_preview(user_id: String, item_id: String, target_cell: Vector2i) - func calculate_damage(attacker: Dictionary, target: Dictionary) -> int: var terrain_defense := get_terrain_defense(target["pos"]) - return max(1, _effective_stat(attacker, "atk") - _effective_stat(target, "def") - terrain_defense) + var effective_bonus := _weapon_effectiveness_bonus(attacker, target) + return max(1, _effective_stat(attacker, "atk") + effective_bonus - _effective_stat(target, "def") - terrain_defense) func calculate_hit_chance(attacker: Dictionary, target: Dictionary) -> int: @@ -851,6 +859,49 @@ func calculate_skill_heal(caster: Dictionary, target: Dictionary, skill: Diction return max(0, min(raw_heal, missing_hp)) +func _weapon_effectiveness_bonus(attacker: Dictionary, target: Dictionary) -> int: + var weapon := _equipped_weapon(attacker) + if weapon.is_empty(): + return 0 + if not _weapon_is_effective_against(weapon, target): + return 0 + return max(0, int(weapon.get("effective_bonus_damage", 0))) + + +func _weapon_is_effective_against(weapon: Dictionary, target: Dictionary) -> bool: + var target_move_type := _weapon_effectiveness_target_type(target) + if target_move_type.is_empty(): + return false + var effective_types = weapon.get("effective_vs_move_types", []) + if typeof(effective_types) != TYPE_ARRAY: + return false + for move_type in effective_types: + if str(move_type) == target_move_type: + return true + return false + + +func _weapon_effectiveness_target_type(target: Dictionary) -> String: + return str(target.get("move_type", "foot")).strip_edges() + + +func _format_move_type(move_type: String) -> String: + return move_type.replace("_", " ").capitalize() + + +func _equipped_weapon(unit: Dictionary) -> Dictionary: + var equipment = unit.get("equipment", {}) + if typeof(equipment) != TYPE_DICTIONARY: + return {} + var weapon_id := str(equipment.get("weapon", "")) + if weapon_id.is_empty(): + return {} + var weapon := get_item_def(weapon_id) + if str(weapon.get("kind", "")) != "weapon": + return {} + return weapon + + func get_status_effect_summary(unit_id: String) -> String: var unit := get_unit(unit_id) if unit.is_empty(): @@ -2630,7 +2681,11 @@ func _resolve_attack(attacker: Dictionary, target: Dictionary, is_counter := fal return {"hit": false, "defeated": false} var damage := calculate_damage(attacker, target) target["hp"] = max(0, int(target["hp"]) - damage) - _emit_log("%s %s %s for %d damage. Hit %d%%." % [attacker["name"], verb, target["name"], damage, hit_chance]) + var effective_bonus := _weapon_effectiveness_bonus(attacker, target) + var effective_text := "" + if effective_bonus > 0: + effective_text = " Effective +%d vs %s." % [effective_bonus, _format_move_type(_weapon_effectiveness_target_type(target))] + _emit_log("%s %s %s for %d damage. Hit %d%%.%s" % [attacker["name"], verb, target["name"], damage, hit_chance, effective_text]) _emit_combat_feedback(target, "-%d" % damage, "damage") if int(target["hp"]) <= 0: diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 7f05f62..73ad539 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -1535,19 +1535,23 @@ func _update_forecast() -> void: var range_text := "ready" if preview["in_range"] else "out of range" var defeat_text := " Defeat" if preview["would_defeat"] else "" + var effective_text := _format_effective_forecast_text(preview, "effective_bonus", "effective_target_type") var counter_text := "" if preview["counter_in_range"]: var counter_defeat := " Defeat" if preview["counter_would_defeat"] else "" - counter_text = "\nCounter %d, Hit %d%%, your HP %d/%d.%s" % [ + var counter_effective_text := _format_effective_forecast_text(preview, "counter_effective_bonus", "counter_effective_target_type") + counter_text = "\nCounter %d%s, Hit %d%%, your HP %d/%d.%s" % [ preview["counter_damage"], + counter_effective_text, preview.get("counter_hit_chance", 0), preview["attacker_hp_after"], selected["max_hp"], counter_defeat ] - forecast_label.text = "Forecast: %s\n%d damage, Hit %d%%, target HP %d/%d.%s%s" % [ + forecast_label.text = "Forecast: %s\n%d damage%s, Hit %d%%, target HP %d/%d.%s%s" % [ range_text, preview["damage"], + effective_text, preview.get("hit_chance", 100), preview["target_hp_after"], target["max_hp"], @@ -1556,6 +1560,16 @@ func _update_forecast() -> void: ] +func _format_effective_forecast_text(preview: Dictionary, bonus_key: String, type_key: String) -> String: + var bonus := int(preview.get(bonus_key, 0)) + if bonus <= 0: + return "" + var move_type := _format_move_type(str(preview.get(type_key, ""))) + if move_type.is_empty(): + move_type = "Target" + return " (Effective +%d vs %s)" % [bonus, move_type] + + func _update_skill_forecast(selected: Dictionary) -> void: var preview := state.get_skill_preview(selected["id"], selected_skill_id, hover_cell) if preview.is_empty(): @@ -3763,11 +3777,37 @@ func _format_equipment_bonus_text(item: Dictionary) -> String: continue var sign := "+" if amount > 0 else "" parts.append("%s %s%d" % [stat.to_upper(), sign, amount]) + var effective_text := _format_equipment_effectiveness_text(item) + if not effective_text.is_empty(): + parts.append(effective_text) if parts.is_empty(): return str(item.get("kind", "equipment")).capitalize() return _join_strings(parts, ", ") +func _format_equipment_effectiveness_text(item: Dictionary) -> String: + if str(item.get("kind", "")) != "weapon": + return "" + var bonus := int(item.get("effective_bonus_damage", 0)) + if bonus <= 0: + return "" + var move_types = item.get("effective_vs_move_types", []) + if typeof(move_types) != TYPE_ARRAY or move_types.is_empty(): + return "" + var labels := [] + for move_type in move_types: + var label := _format_move_type(str(move_type)) + if not label.is_empty(): + labels.append(label) + if labels.is_empty(): + return "" + return "Effective +%d vs %s" % [bonus, _join_strings(labels, "/")] + + +func _format_move_type(move_type: String) -> String: + return move_type.strip_edges().replace("_", " ").capitalize() + + func _on_equip_item_pressed(item_id: String) -> void: var selected := state.get_selected_unit() if selected.is_empty(): diff --git a/tools/validate_data.ps1 b/tools/validate_data.ps1 index b33e2df..ac2c480 100644 --- a/tools/validate_data.ps1 +++ b/tools/validate_data.ps1 @@ -105,10 +105,30 @@ $validSkillEffectTypes = @("stat_bonus", "damage_over_time", "action_lock") $validSkillAreaShapes = @("single", "diamond") $validDialogueSides = @("left", "right") $validPortraitPathPattern = '^res://.+\.(png|jpg|jpeg|webp)$' +$validMoveTypes = New-Object System.Collections.Generic.HashSet[string] $knownFlagIds = New-Object System.Collections.Generic.HashSet[string] $knownFlagValueKinds = @{} $joinedOfficerIds = New-Object System.Collections.Generic.HashSet[string] +foreach ($classProp in $classes.PSObject.Properties) { + $moveType = [string](Get-Prop $classProp.Value "move_type" "foot") + if (-not [string]::IsNullOrWhiteSpace($moveType)) { + $validMoveTypes.Add($moveType) | Out-Null + } +} +foreach ($terrainProp in $terrain.PSObject.Properties) { + $moveCost = Get-Prop $terrainProp.Value "move_cost" $null + if ($null -eq $moveCost) { + continue + } + foreach ($moveCostProp in $moveCost.PSObject.Properties) { + $moveType = [string]$moveCostProp.Name + if (-not [string]::IsNullOrWhiteSpace($moveType)) { + $validMoveTypes.Add($moveType) | Out-Null + } + } +} + foreach ($officerIdValue in (Get-Prop $campaign "initial_joined_officers" @())) { $officerId = [string]$officerIdValue if ([string]::IsNullOrWhiteSpace($officerId)) { @@ -679,6 +699,46 @@ function Check-Item-Effects() { Fail "Weapon item $itemId is missing weapon_type." } Check-Skill-Range (Get-Prop $item "range" 1) "Item $itemId" + $hasEffectiveTypes = Has-Prop $item "effective_vs_move_types" + $hasEffectiveBonus = Has-Prop $item "effective_bonus_damage" + if ($hasEffectiveTypes -or $hasEffectiveBonus) { + if (-not $hasEffectiveTypes -or -not $hasEffectiveBonus) { + Fail "Weapon item $itemId effectiveness requires effective_vs_move_types and effective_bonus_damage." + } + $effectiveTypes = @((Get-Prop $item "effective_vs_move_types" @())) + if ($effectiveTypes.Count -eq 0) { + Fail "Weapon item $itemId effective_vs_move_types must be a non-empty array." + } + $seenEffectiveTypes = New-Object System.Collections.Generic.HashSet[string] + foreach ($moveTypeValue in $effectiveTypes) { + $moveType = [string]$moveTypeValue + if ([string]::IsNullOrWhiteSpace($moveType) -or -not ($moveType -match '^[a-z0-9_]+$')) { + Fail "Weapon item $itemId has unstable effective move type: $moveType" + } + if (-not $validMoveTypes.Contains($moveType)) { + Fail "Weapon item $itemId references unknown effective move type: $moveType" + } + if (-not $seenEffectiveTypes.Add($moveType)) { + Fail "Weapon item $itemId duplicates effective move type: $moveType" + } + } + $effectiveBonus = Get-Prop $item "effective_bonus_damage" 0 + if (-not ($effectiveBonus -is [int] -or $effectiveBonus -is [long])) { + Fail "Weapon item $itemId effective_bonus_damage must be an integer." + } + $effectiveBonusValue = [int]$effectiveBonus + if ($effectiveBonusValue -lt 1 -or $effectiveBonusValue -gt 5) { + Fail "Weapon item $itemId effective_bonus_damage must be between 1 and 5." + } + } + } + if ($kind -ne "weapon") { + if (Has-Prop $item "effective_vs_move_types") { + Fail "Item $itemId has effectiveness move types but is not a weapon." + } + if (Has-Prop $item "effective_bonus_damage") { + Fail "Item $itemId has effectiveness bonus but is not a weapon." + } } if ($kind -eq "armor" -and (-not (Has-Prop $item "armor_type"))) { Fail "Armor item $itemId is missing armor_type."