Add first support tactic
This commit is contained in:
@@ -13,7 +13,7 @@ Godot 4 tactical RPG prototype inspired by classic turn-based Romance of the Thr
|
||||
- Damage forecast for selected-unit attacks.
|
||||
- Terrain defense and avoid affect physical attack damage and hit chance.
|
||||
- Min/max attack ranges and counterattack forecasts.
|
||||
- MP tactics with low/high damage options, healing options, a skill list menu, damage/healing previews, and target overlays.
|
||||
- MP tactics with low/high damage options, healing options, a first support buff, a skill list menu, previews, and target overlays.
|
||||
- Campaign inventory consumables with an item menu, HP/MP recovery preview, and target overlay.
|
||||
- Weapon, armor, and accessory rewards can be stored, displayed, and equipped from the battle HUD.
|
||||
- Scenario-specific pre-battle shop purchases spend campaign gold, save immediately, and sync into the upcoming battle inventory.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"move_type": "foot",
|
||||
"move": 4,
|
||||
"attack_range": [1, 1],
|
||||
"skills": ["spark", "fire_tactic", "mend"],
|
||||
"skills": ["spark", "fire_tactic", "mend", "guard_order"],
|
||||
"growth": { "hp": "B", "mp": "B", "atk": "B", "def": "B", "int": "A", "agi": "B" },
|
||||
"base_bonus": { "hp": 0, "atk": 0, "def": 0 },
|
||||
"equipment_slots": {
|
||||
@@ -21,7 +21,7 @@
|
||||
"move_type": "foot",
|
||||
"move": 5,
|
||||
"attack_range": [1, 1],
|
||||
"skills": ["spark", "fire_tactic", "mend", "great_mend"],
|
||||
"skills": ["spark", "fire_tactic", "mend", "great_mend", "guard_order"],
|
||||
"growth": { "hp": "A", "mp": "B", "atk": "A", "def": "B", "int": "A", "agi": "B" },
|
||||
"base_bonus": { "hp": 4, "atk": 1, "def": 1, "int": 1 },
|
||||
"equipment_slots": {
|
||||
@@ -160,7 +160,7 @@
|
||||
"move_type": "foot",
|
||||
"move": 4,
|
||||
"attack_range": [1, 1],
|
||||
"skills": ["spark", "fire_tactic", "mend"],
|
||||
"skills": ["spark", "fire_tactic", "mend", "guard_order"],
|
||||
"growth": { "hp": "C", "mp": "A", "atk": "E", "def": "D", "int": "A", "agi": "C" },
|
||||
"base_bonus": { "hp": -2, "mp": 8, "atk": -2, "def": -1, "int": 2 },
|
||||
"equipment_slots": {
|
||||
@@ -176,7 +176,7 @@
|
||||
"move_type": "foot",
|
||||
"move": 4,
|
||||
"attack_range": [1, 1],
|
||||
"skills": ["spark", "fire_tactic", "mend", "great_mend"],
|
||||
"skills": ["spark", "fire_tactic", "mend", "great_mend", "guard_order"],
|
||||
"growth": { "hp": "B", "mp": "A", "atk": "E", "def": "C", "int": "A", "agi": "C" },
|
||||
"base_bonus": { "hp": 2, "mp": 6, "atk": -2, "def": 1, "int": 2 },
|
||||
"equipment_slots": {
|
||||
|
||||
@@ -43,5 +43,17 @@
|
||||
"range": [0, 2],
|
||||
"power": 20,
|
||||
"stat": "int"
|
||||
},
|
||||
"guard_order": {
|
||||
"name": "Guard Order",
|
||||
"kind": "support",
|
||||
"target": "ally",
|
||||
"mp_cost": 5,
|
||||
"range": [0, 2],
|
||||
"power": 0,
|
||||
"stat": "int",
|
||||
"effects": [
|
||||
{ "type": "stat_bonus", "stat": "def", "amount": 4, "duration_turns": 1 }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
4. Show scenario briefing, including the optional pre-battle shop, Armory, Roster, and Formation setup.
|
||||
5. Player selects a unit.
|
||||
6. Briefing completion dispatches battle-begin events, including opening dialogue.
|
||||
7. Unit moves, attacks, chooses a tactic or consumable item from the side menu, changes equipment, counters, gains EXP, levels up, promotes at class thresholds, or waits. Tactic buttons show MP cost, kind, range, and power. Physical attacks roll hit chance from unit agility and target terrain avoid; misses give reduced EXP. The scene plays placeholder SFX for core action and UI feedback.
|
||||
7. Unit moves, attacks, chooses a tactic or consumable item from the side menu, changes equipment, counters, gains EXP, levels up, promotes at class thresholds, or waits. Tactic buttons show MP cost, kind, range, and power or support effect. Physical attacks roll hit chance from unit agility and target terrain avoid; misses give reduced EXP. The scene plays placeholder SFX for core action and UI feedback.
|
||||
8. Player ends turn.
|
||||
9. Enemy AI moves, attacks, and can cast damage or healing tactics through the same combat and skill resolvers.
|
||||
10. Battle checks scenario-defined defeat conditions first, including commander loss and turn limits, then victory conditions such as enemy defeat or reaching a marked destination tile.
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
## Save Boundary
|
||||
|
||||
`BattleState` owns tactical runtime fields. `CampaignState` saves only campaign-facing data: completed scenario ids, gold, inventory counts, joined officers, campaign flags, and a player roster snapshot. It does not save map positions, pre-battle Formation choices, action flags, or transient battle occupancy.
|
||||
`BattleState` owns tactical runtime fields. `CampaignState` saves only campaign-facing data: completed scenario ids, gold, inventory counts, joined officers, campaign flags, and a player roster snapshot. It does not save map positions, pre-battle Formation choices, action flags, temporary status effects, or transient battle occupancy.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ Classes provide movement, min/max attack range, skill access, growth grades, cla
|
||||
|
||||
## Skills
|
||||
|
||||
Skills define MP tactics. A class, officer, or scenario deployment can add skill ids to the runtime unit. `range` is `[min, max]` in Manhattan distance and can use `0` as the minimum for self-targeting support skills. Enemy AI can use `damage` and `heal` skills when it has enough MP.
|
||||
Skills define MP tactics. A class, officer, or scenario deployment can add skill ids to the runtime unit. `range` is `[min, max]` in Manhattan distance and can use `0` as the minimum for self-targeting support skills. Skill `kind` can be `damage`, `heal`, or `support`. Enemy AI can use `damage` and `heal` skills when it has enough MP.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -232,7 +232,9 @@ Skills define MP tactics. A class, officer, or scenario deployment can add skill
|
||||
}
|
||||
```
|
||||
|
||||
The current single-target tactic set includes `spark`, `fire_tactic`, `blaze`, `mend`, and `great_mend`. Hero and strategist classes get low-cost Spark alongside Fire/Mend, promoted command/advisor classes add Great Mend, and late scenario deployments can add stronger pressure skills such as Blaze without changing their base class.
|
||||
Support skills use an `effects` array. The first supported effect is `stat_bonus`, with `stat` in `atk`, `def`, `int`, or `agi`, a non-zero `amount`, and positive `duration_turns`. These effects are battle-only and expire when the target's team begins enough future phases.
|
||||
|
||||
The current single-target tactic set includes `spark`, `fire_tactic`, `blaze`, `mend`, `great_mend`, and `guard_order`. Hero and strategist classes get low-cost Spark alongside Fire/Mend and Guard Order, promoted command/advisor classes add Great Mend, and late scenario deployments can add stronger pressure skills such as Blaze without changing their base class.
|
||||
|
||||
## Terrain
|
||||
|
||||
@@ -461,7 +463,7 @@ Event-spawned units use the same compact deployment shape as scenario starting u
|
||||
|
||||
## Current Implementation
|
||||
|
||||
`scripts/core/data_catalog.gd` reads definition files once and `BattleState._apply_battle_data()` accepts either legacy `units` or catalog-backed `deployments`. Runtime units now carry `min_range`, `range`, `skills`, `exp`, `growth`, and `growth_bonus` so the battle resolver can handle counterattacks, MP tactics, EXP, level-ups, and enemy tactic AI without changing scenario files.
|
||||
`scripts/core/data_catalog.gd` reads definition files once and `BattleState._apply_battle_data()` accepts either legacy `units` or catalog-backed `deployments`. Runtime units now carry `min_range`, `range`, `skills`, `exp`, `growth`, `growth_bonus`, and battle-only `status_effects` so the battle resolver can handle counterattacks, MP tactics, support effects, EXP, level-ups, and enemy tactic AI without changing scenario files.
|
||||
|
||||
`scripts/core/campaign_state.gd` writes `user://campaign_save.json` with `save_version`, completed scenarios, gold, inventory counts, joined officers, pending post-battle choice scenario id, applied post-battle choice ids, campaign flags, and player roster progression. Roster snapshots include level, EXP, stats, class id/name, class-derived movement/range/growth fields, skills, and equipment. Rewards, joined/left officers, and post-battle choices are guarded by completed scenario id so restarting a finished battle does not duplicate gold, items, membership changes, or branch decisions. On load, completed-scenario officer join/leave transitions are reconciled without replaying gold or item rewards, which lets new officer rewards added to older completed scenarios become available to existing saves.
|
||||
|
||||
|
||||
@@ -32,9 +32,9 @@
|
||||
- Weapon, armor, and accessory slots. Basic stat 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 single-target damage/heal tactics, a skill list menu with range/power hints, and enemy tactic AI exist.
|
||||
- Skills, tactics, and MP. Multiple single-target damage/heal tactics, a first support buff, a skill list menu with range/effect hints, and enemy tactic AI exist.
|
||||
- Consumable item use. Basic global inventory, Bean HP recovery, Wine MP recovery, item menu, preview, and target overlay exist.
|
||||
- Counterattacks exist. Support effects are still planned.
|
||||
- Counterattacks exist. A first battle-only support status effect exists; richer support and debuff effects are still planned.
|
||||
- Battle EXP, level-ups, class promotion, and campaign roster persistence exist.
|
||||
|
||||
## Milestone 3: Scenario Layer
|
||||
|
||||
@@ -18,6 +18,7 @@ const EXP_COUNTER := 5
|
||||
const EXP_MISS := 2
|
||||
const EXP_SKILL := 12
|
||||
const EXP_HEAL := 8
|
||||
const EXP_SUPPORT := 8
|
||||
const EXP_DEFEAT_BONUS := 30
|
||||
const EXP_LEVEL := 100
|
||||
const BASE_HIT_CHANCE := 90
|
||||
@@ -453,6 +454,7 @@ func _prepare_unit(source_unit: Dictionary) -> Dictionary:
|
||||
unit["controllable"] = bool(unit.get("controllable", true))
|
||||
unit["persist_progression"] = bool(unit.get("persist_progression", true))
|
||||
unit["ai_target_priority"] = max(0, int(unit.get("ai_target_priority", 0)))
|
||||
unit["status_effects"] = []
|
||||
unit["acted"] = false
|
||||
unit["moved"] = false
|
||||
return unit
|
||||
@@ -593,12 +595,17 @@ func try_cast_selected_skill(skill_id: String, target_cell: Vector2i) -> bool:
|
||||
if skill_kind == "heal" and calculate_skill_heal(caster, target, skill) <= 0:
|
||||
_emit_log("%s is already at full HP." % target["name"])
|
||||
return false
|
||||
if skill_kind == "support" and not _skill_has_support_effects(skill):
|
||||
return false
|
||||
|
||||
caster["mp"] = max(0, int(caster.get("mp", 0)) - int(skill.get("mp_cost", 0)))
|
||||
if skill_kind == "heal":
|
||||
var healed := _resolve_skill_heal(caster, target, skill)
|
||||
if healed > 0:
|
||||
_grant_experience(caster, EXP_HEAL)
|
||||
elif skill_kind == "support":
|
||||
if _resolve_skill_support(caster, target, skill_id, skill):
|
||||
_grant_experience(caster, EXP_SUPPORT)
|
||||
else:
|
||||
var defeated := _resolve_skill_damage(caster, target, skill)
|
||||
_grant_experience(caster, EXP_SKILL + (EXP_DEFEAT_BONUS if defeated else 0))
|
||||
@@ -712,6 +719,9 @@ func get_skill_preview(caster_id: String, skill_id: String, target_cell: Vector2
|
||||
var heal_amount := calculate_skill_heal(caster, target, skill)
|
||||
preview["heal"] = heal_amount
|
||||
preview["target_hp_after"] = min(int(target.get("max_hp", 1)), int(target.get("hp", 0)) + heal_amount)
|
||||
elif skill_kind == "support":
|
||||
preview["effect_text"] = _format_support_effects(skill)
|
||||
preview["already_active"] = _has_status_effect_from_skill(target, skill_id)
|
||||
else:
|
||||
var damage := calculate_skill_damage(caster, target, skill)
|
||||
preview["damage"] = damage
|
||||
@@ -748,29 +758,151 @@ 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, int(attacker["atk"]) - int(target["def"]) - terrain_defense)
|
||||
return max(1, _effective_stat(attacker, "atk") - _effective_stat(target, "def") - terrain_defense)
|
||||
|
||||
|
||||
func calculate_hit_chance(attacker: Dictionary, target: Dictionary) -> int:
|
||||
var terrain_avoid := get_terrain_avoid(target["pos"])
|
||||
var agility_delta := int(attacker.get("agi", 0)) - int(target.get("agi", 0))
|
||||
var agility_delta := _effective_stat(attacker, "agi") - _effective_stat(target, "agi")
|
||||
return clampi(BASE_HIT_CHANCE + agility_delta - terrain_avoid, MIN_HIT_CHANCE, 100)
|
||||
|
||||
|
||||
func calculate_skill_damage(caster: Dictionary, target: Dictionary, skill: Dictionary) -> int:
|
||||
var terrain_defense := get_terrain_defense(target["pos"])
|
||||
var stat_name := str(skill.get("stat", "int"))
|
||||
var stat_value := int(caster.get(stat_name, 0))
|
||||
return max(1, int(skill.get("power", 1)) + stat_value - int(target.get("def", 0)) - terrain_defense)
|
||||
var stat_value := _effective_stat(caster, stat_name)
|
||||
return max(1, int(skill.get("power", 1)) + stat_value - _effective_stat(target, "def") - terrain_defense)
|
||||
|
||||
|
||||
func calculate_skill_heal(caster: Dictionary, target: Dictionary, skill: Dictionary) -> int:
|
||||
var stat_name := str(skill.get("stat", "int"))
|
||||
var raw_heal := int(skill.get("power", 1)) + int(caster.get(stat_name, 0))
|
||||
var raw_heal := int(skill.get("power", 1)) + _effective_stat(caster, stat_name)
|
||||
var missing_hp := int(target.get("max_hp", 1)) - int(target.get("hp", 0))
|
||||
return max(0, min(raw_heal, missing_hp))
|
||||
|
||||
|
||||
func get_status_effect_summary(unit_id: String) -> String:
|
||||
var unit := get_unit(unit_id)
|
||||
if unit.is_empty():
|
||||
return ""
|
||||
return _format_active_status_effects(unit)
|
||||
|
||||
|
||||
func _effective_stat(unit: Dictionary, stat: String) -> int:
|
||||
var base_value := int(unit.get(stat, 0))
|
||||
for effect in _active_status_effects(unit):
|
||||
if str(effect.get("stat", "")) != stat:
|
||||
continue
|
||||
base_value += int(effect.get("amount", 0))
|
||||
if stat == "atk":
|
||||
return max(1, base_value)
|
||||
return max(0, base_value)
|
||||
|
||||
|
||||
func _active_status_effects(unit: Dictionary) -> Array:
|
||||
var result := []
|
||||
if typeof(unit.get("status_effects", [])) != TYPE_ARRAY:
|
||||
return result
|
||||
for effect in unit.get("status_effects", []):
|
||||
if typeof(effect) != TYPE_DICTIONARY:
|
||||
continue
|
||||
if int(effect.get("remaining_phases", 0)) <= 0:
|
||||
continue
|
||||
result.append(effect)
|
||||
return result
|
||||
|
||||
|
||||
func _skill_has_support_effects(skill: Dictionary) -> bool:
|
||||
for effect in skill.get("effects", []):
|
||||
if typeof(effect) != TYPE_DICTIONARY:
|
||||
continue
|
||||
if str(effect.get("type", "")) == "stat_bonus" and _is_supported_status_stat(str(effect.get("stat", ""))) and int(effect.get("amount", 0)) != 0:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _resolve_skill_support(caster: Dictionary, target: Dictionary, skill_id: String, skill: Dictionary) -> bool:
|
||||
if not _skill_has_support_effects(skill):
|
||||
return false
|
||||
var status_effects := _status_effects_without_skill(target, skill_id)
|
||||
for effect in skill.get("effects", []):
|
||||
if typeof(effect) != TYPE_DICTIONARY:
|
||||
continue
|
||||
if str(effect.get("type", "")) != "stat_bonus":
|
||||
continue
|
||||
var stat := str(effect.get("stat", ""))
|
||||
var amount := int(effect.get("amount", 0))
|
||||
if not _is_supported_status_stat(stat) or amount == 0:
|
||||
continue
|
||||
status_effects.append({
|
||||
"source_skill": skill_id,
|
||||
"name": str(skill.get("name", skill_id)),
|
||||
"stat": stat,
|
||||
"amount": amount,
|
||||
"remaining_phases": max(1, int(effect.get("duration_turns", 1)))
|
||||
})
|
||||
target["status_effects"] = status_effects
|
||||
_emit_log("%s casts %s on %s. %s" % [
|
||||
caster["name"],
|
||||
skill.get("name", "Skill"),
|
||||
target["name"],
|
||||
_format_support_effects(skill)
|
||||
])
|
||||
return true
|
||||
|
||||
|
||||
func _status_effects_without_skill(unit: Dictionary, skill_id: String) -> Array:
|
||||
var result := []
|
||||
for effect in _active_status_effects(unit):
|
||||
if str(effect.get("source_skill", "")) == skill_id:
|
||||
continue
|
||||
result.append(effect)
|
||||
return result
|
||||
|
||||
|
||||
func _has_status_effect_from_skill(unit: Dictionary, skill_id: String) -> bool:
|
||||
for effect in _active_status_effects(unit):
|
||||
if str(effect.get("source_skill", "")) == skill_id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _format_support_effects(skill: Dictionary) -> String:
|
||||
var parts := []
|
||||
for effect in skill.get("effects", []):
|
||||
if typeof(effect) != TYPE_DICTIONARY:
|
||||
continue
|
||||
if str(effect.get("type", "")) != "stat_bonus":
|
||||
continue
|
||||
var stat := str(effect.get("stat", ""))
|
||||
var amount := int(effect.get("amount", 0))
|
||||
if not _is_supported_status_stat(stat) or amount == 0:
|
||||
continue
|
||||
var sign := "+" if amount > 0 else ""
|
||||
var duration := max(1, int(effect.get("duration_turns", 1)))
|
||||
var duration_text := "next phase" if duration == 1 else "%d phases" % duration
|
||||
parts.append("%s %s%d until %s" % [stat.to_upper(), sign, amount, duration_text])
|
||||
if parts.is_empty():
|
||||
return "No support effect."
|
||||
return _join_strings(parts, ", ")
|
||||
|
||||
|
||||
func _format_active_status_effects(unit: Dictionary) -> String:
|
||||
var parts := []
|
||||
for effect in _active_status_effects(unit):
|
||||
var stat := str(effect.get("stat", ""))
|
||||
var amount := int(effect.get("amount", 0))
|
||||
if not _is_supported_status_stat(stat) or amount == 0:
|
||||
continue
|
||||
var sign := "+" if amount > 0 else ""
|
||||
parts.append("%s %s%d" % [stat.to_upper(), sign, amount])
|
||||
return _join_strings(parts, ", ")
|
||||
|
||||
|
||||
func _is_supported_status_stat(stat: String) -> bool:
|
||||
return stat == "atk" or stat == "def" or stat == "int" or stat == "agi"
|
||||
|
||||
|
||||
func try_wait_selected() -> bool:
|
||||
var unit := get_selected_unit()
|
||||
if unit.is_empty() or battle_status != STATUS_ACTIVE:
|
||||
@@ -2366,12 +2498,36 @@ func _spawn_event_deployment(deployment) -> bool:
|
||||
|
||||
|
||||
func _reset_team_actions(team: String) -> void:
|
||||
_expire_status_effects_for_team(team)
|
||||
for unit in units:
|
||||
if unit.get("team", "") == team and unit.get("alive", false):
|
||||
unit["acted"] = false
|
||||
unit["moved"] = false
|
||||
|
||||
|
||||
func _expire_status_effects_for_team(team: String) -> void:
|
||||
for unit in units:
|
||||
if unit.get("team", "") != team:
|
||||
continue
|
||||
if typeof(unit.get("status_effects", [])) != TYPE_ARRAY:
|
||||
continue
|
||||
var kept_effects := []
|
||||
var expired_names := []
|
||||
for effect in _active_status_effects(unit):
|
||||
var next_remaining := int(effect.get("remaining_phases", 1)) - 1
|
||||
if next_remaining > 0:
|
||||
var kept_effect: Dictionary = effect.duplicate(true)
|
||||
kept_effect["remaining_phases"] = next_remaining
|
||||
kept_effects.append(kept_effect)
|
||||
else:
|
||||
var effect_name := str(effect.get("name", "Support"))
|
||||
if not expired_names.has(effect_name):
|
||||
expired_names.append(effect_name)
|
||||
unit["status_effects"] = kept_effects
|
||||
for expired_effect_name in expired_names:
|
||||
_emit_log("%s's %s fades." % [unit["name"], expired_effect_name])
|
||||
|
||||
|
||||
func _manhattan(a: Vector2i, b: Vector2i) -> int:
|
||||
return absi(a.x - b.x) + absi(a.y - b.y)
|
||||
|
||||
@@ -2380,6 +2536,15 @@ func _format_cell(cell: Vector2i) -> String:
|
||||
return "(%d, %d)" % [cell.x + 1, cell.y + 1]
|
||||
|
||||
|
||||
func _join_strings(values: Array, delimiter: String) -> String:
|
||||
var text := ""
|
||||
for value in values:
|
||||
if not text.is_empty():
|
||||
text += delimiter
|
||||
text += str(value)
|
||||
return text
|
||||
|
||||
|
||||
func _emit_log(message: String) -> void:
|
||||
log_added.emit(message)
|
||||
|
||||
|
||||
@@ -202,12 +202,12 @@ func _create_hud() -> void:
|
||||
|
||||
selected_label = Label.new()
|
||||
selected_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
selected_label.custom_minimum_size = Vector2(420, 72)
|
||||
selected_label.custom_minimum_size = Vector2(420, 92)
|
||||
side_column.add_child(selected_label)
|
||||
|
||||
cell_info_label = Label.new()
|
||||
cell_info_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
cell_info_label.custom_minimum_size = Vector2(420, 90)
|
||||
cell_info_label.custom_minimum_size = Vector2(420, 106)
|
||||
side_column.add_child(cell_info_label)
|
||||
|
||||
forecast_label = Label.new()
|
||||
@@ -567,7 +567,7 @@ func _play_log_sfx(message: String) -> void:
|
||||
if message.contains(" for "):
|
||||
_play_sfx("hit_heavy")
|
||||
elif message.contains(" casts "):
|
||||
if message.contains("restoring"):
|
||||
if message.contains("restoring") or message.contains(" until "):
|
||||
_play_sfx("skill_cast")
|
||||
else:
|
||||
_play_sfx("fire_skill")
|
||||
@@ -882,6 +882,9 @@ func _update_hud() -> void:
|
||||
selected.get("min_range", 1),
|
||||
selected["range"]
|
||||
]
|
||||
var selected_effects := state.get_status_effect_summary(selected["id"])
|
||||
if not selected_effects.is_empty():
|
||||
selected_label.text += "\nEffects: %s" % selected_effects
|
||||
wait_button.disabled = selected.get("acted", false) or not state.can_player_act() or _is_input_locked()
|
||||
_update_tactic_button(selected)
|
||||
_update_item_button(selected)
|
||||
@@ -933,6 +936,9 @@ func _update_cell_info() -> void:
|
||||
unit.get("min_range", 1),
|
||||
unit["range"]
|
||||
]
|
||||
var status_effects := state.get_status_effect_summary(unit["id"])
|
||||
if not status_effects.is_empty():
|
||||
text += "\nEffects: %s" % status_effects
|
||||
|
||||
cell_info_label.text = text
|
||||
|
||||
@@ -1011,6 +1017,16 @@ func _update_skill_forecast(selected: Dictionary) -> void:
|
||||
preview["target_hp_after"],
|
||||
target["max_hp"]
|
||||
]
|
||||
elif preview["kind"] == "support":
|
||||
var refresh_text := " Refresh." if bool(preview.get("already_active", false)) else ""
|
||||
forecast_label.text = "%s: %s, %d MP.%s\n%s.%s" % [
|
||||
preview["skill_name"],
|
||||
range_text,
|
||||
preview["mp_cost"],
|
||||
mp_text,
|
||||
preview.get("effect_text", "Support effect"),
|
||||
refresh_text
|
||||
]
|
||||
else:
|
||||
var defeat_text := " Defeat" if preview["would_defeat"] else ""
|
||||
forecast_label.text = "%s: %s, %d MP.%s\n%d damage, target HP %d/%d.%s" % [
|
||||
@@ -2277,9 +2293,27 @@ func _format_tactic_button_text(skill_id: String, skill: Dictionary, selected: D
|
||||
var mp_cost := int(skill.get("mp_cost", 0))
|
||||
var kind := str(skill.get("kind", "skill")).capitalize()
|
||||
var range_text := _format_tactic_range_text(skill)
|
||||
var power_text := int(skill.get("power", 0))
|
||||
var effect_text := _format_tactic_effect_text(skill)
|
||||
var disabled_text := " - MP" if int(selected.get("mp", 0)) < mp_cost else ""
|
||||
return "%s%s %dMP %s R%s P%d%s" % [marker, skill_name, mp_cost, kind, range_text, power_text, disabled_text]
|
||||
return "%s%s %dMP %s R%s %s%s" % [marker, skill_name, mp_cost, kind, range_text, effect_text, disabled_text]
|
||||
|
||||
|
||||
func _format_tactic_effect_text(skill: Dictionary) -> String:
|
||||
if str(skill.get("kind", "")) == "support":
|
||||
var parts := []
|
||||
for effect in skill.get("effects", []):
|
||||
if typeof(effect) != TYPE_DICTIONARY:
|
||||
continue
|
||||
if str(effect.get("type", "")) != "stat_bonus":
|
||||
continue
|
||||
var amount := int(effect.get("amount", 0))
|
||||
if amount == 0:
|
||||
continue
|
||||
var sign := "+" if amount > 0 else ""
|
||||
parts.append("%s %s%d" % [str(effect.get("stat", "")).to_upper(), sign, amount])
|
||||
if not parts.is_empty():
|
||||
return _join_strings(parts, ", ")
|
||||
return "P%d" % int(skill.get("power", 0))
|
||||
|
||||
|
||||
func _format_tactic_range_text(skill: Dictionary) -> String:
|
||||
|
||||
@@ -97,9 +97,10 @@ $validActions = @("log", "dialogue", "set_objective", "spawn_deployment", "spawn
|
||||
$validEffects = @("heal_hp", "heal_mp")
|
||||
$validItemKinds = @("weapon", "armor", "accessory", "consumable")
|
||||
$validBonusStats = @("hp", "mp", "atk", "def", "int", "agi")
|
||||
$validSkillKinds = @("damage", "heal")
|
||||
$validSkillKinds = @("damage", "heal", "support")
|
||||
$validSkillTargets = @("enemy", "ally", "self", "any")
|
||||
$validSkillStats = @("atk", "def", "int", "agi")
|
||||
$validSkillEffectTypes = @("stat_bonus")
|
||||
$knownFlagIds = New-Object System.Collections.Generic.HashSet[string]
|
||||
$knownFlagValueKinds = @{}
|
||||
$joinedOfficerIds = New-Object System.Collections.Generic.HashSet[string]
|
||||
@@ -658,6 +659,32 @@ function Check-Skill-Definitions() {
|
||||
if (-not $validSkillStats.Contains($stat)) {
|
||||
Fail "Skill $skillId references unsupported stat: $stat"
|
||||
}
|
||||
$effects = Get-Prop $skill "effects" @()
|
||||
if ($kind -eq "support" -and @($effects).Count -eq 0) {
|
||||
Fail "Support skill $skillId must define at least one effect."
|
||||
}
|
||||
foreach ($effect in @($effects)) {
|
||||
if ($effect -isnot [pscustomobject]) {
|
||||
Fail "Skill $skillId has malformed effect."
|
||||
continue
|
||||
}
|
||||
$effectType = [string](Get-Prop $effect "type" "")
|
||||
if (-not $validSkillEffectTypes.Contains($effectType)) {
|
||||
Fail "Skill $skillId has unknown effect: $effectType"
|
||||
}
|
||||
if ($effectType -eq "stat_bonus") {
|
||||
$effectStat = [string](Get-Prop $effect "stat" "")
|
||||
if (-not $validSkillStats.Contains($effectStat)) {
|
||||
Fail "Skill $skillId effect references unsupported stat: $effectStat"
|
||||
}
|
||||
if ([int](Get-Prop $effect "amount" 0) -eq 0) {
|
||||
Fail "Skill $skillId stat_bonus effect must have non-zero amount."
|
||||
}
|
||||
if ([int](Get-Prop $effect "duration_turns" 1) -le 0) {
|
||||
Fail "Skill $skillId stat_bonus effect must have positive duration_turns."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user