Add enemy threat overlay

This commit is contained in:
2026-06-18 10:55:46 +09:00
parent d69453472d
commit 65e2508257
6 changed files with 168 additions and 4 deletions

View File

@@ -1223,6 +1223,28 @@ func get_item_target_cells(unit_id: String, item_id: String) -> Array[Vector2i]:
return _item_target_cells_from(unit, item)
func get_threat_cells(source_team := TEAM_ENEMY) -> Array[Vector2i]:
var result: Array[Vector2i] = []
for unit in get_living_units(source_team):
for cell in _unit_threat_cells(unit):
if not result.has(cell):
result.append(cell)
return result
func get_threatening_unit_names(cell: Vector2i, source_team := TEAM_ENEMY) -> Array[String]:
var result: Array[String] = []
if not is_inside(cell):
return result
for unit in get_living_units(source_team):
if not _unit_threat_cells(unit).has(cell):
continue
var unit_name := str(unit.get("name", unit.get("id", "Unit")))
if not result.has(unit_name):
result.append(unit_name)
return result
func get_objective_cells() -> Array[Vector2i]:
var result: Array[Vector2i] = []
_collect_objective_cells(battle_conditions.get("victory", {}), result)
@@ -1685,6 +1707,93 @@ func _movement_range_for_unit(unit: Dictionary) -> Array[Vector2i]:
return result
func _unit_threat_cells(unit: Dictionary) -> Array[Vector2i]:
var result: Array[Vector2i] = []
if unit.is_empty() or not unit.get("alive", false) or not unit.get("deployed", true):
return result
if not bool(unit.get("controllable", true)):
return result
for cell in _unit_physical_threat_cells(unit):
if not result.has(cell):
result.append(cell)
for cell in _unit_skill_threat_cells(unit):
if not result.has(cell):
result.append(cell)
return result
func _unit_threat_origins(unit: Dictionary) -> Array[Vector2i]:
var result: Array[Vector2i] = []
var origin: Vector2i = unit.get("pos", Vector2i(-1, -1))
if not is_inside(origin):
return result
result.append(origin)
for cell in _movement_range_for_unit(unit):
if not result.has(cell):
result.append(cell)
return result
func _unit_physical_threat_cells(unit: Dictionary) -> Array[Vector2i]:
var result: Array[Vector2i] = []
if _unit_has_action_lock(unit, "attack"):
return result
for origin in _unit_threat_origins(unit):
for cell in _attack_cells_from(unit, origin):
if not result.has(cell):
result.append(cell)
return result
func _unit_skill_threat_cells(unit: Dictionary) -> Array[Vector2i]:
var result: Array[Vector2i] = []
if _unit_has_action_lock(unit, "skill"):
return result
if typeof(unit.get("skills", [])) != TYPE_ARRAY:
return result
for skill_id_value in unit.get("skills", []):
var skill_id := str(skill_id_value)
var skill := get_skill_def(skill_id)
if skill.is_empty():
continue
if int(unit.get("mp", 0)) < int(skill.get("mp_cost", 0)):
continue
if not _skill_threatens_opponents(skill):
continue
for origin in _unit_threat_origins(unit):
for cell in _skill_cells_from_origin(unit, skill, origin):
if not result.has(cell):
result.append(cell)
return result
func _skill_threatens_opponents(skill: Dictionary) -> bool:
var target_rule := str(skill.get("target", "enemy"))
if target_rule != "enemy" and target_rule != "any":
return false
var skill_kind := str(skill.get("kind", "damage"))
if skill_kind == "damage":
return true
if skill_kind == "support":
return _skill_has_harmful_support_effects(skill)
return false
func _skill_has_harmful_support_effects(skill: Dictionary) -> bool:
for effect in skill.get("effects", []):
if typeof(effect) != TYPE_DICTIONARY:
continue
var effect_type := str(effect.get("type", ""))
if effect_type == "damage_over_time" and int(effect.get("amount", 0)) > 0:
return true
if effect_type == "action_lock" and not str(effect.get("action", "")).strip_edges().is_empty():
return true
if effect_type == "stat_bonus" and int(effect.get("amount", 0)) < 0:
return true
return false
func _attack_cells_from(unit: Dictionary, origin: Vector2i) -> Array[Vector2i]:
var result: Array[Vector2i] = []
var attack_range := int(unit.get("range", 1))
@@ -1707,11 +1816,14 @@ func _is_in_attack_range(attacker: Dictionary, target_cell: Vector2i) -> bool:
func _skill_cells_from(unit: Dictionary, skill: Dictionary) -> Array[Vector2i]:
return _skill_cells_from_origin(unit, skill, unit["pos"])
func _skill_cells_from_origin(_unit: Dictionary, skill: Dictionary, origin: Vector2i) -> Array[Vector2i]:
var result: Array[Vector2i] = []
var range_pair := _skill_range_pair(skill)
var max_range := int(range_pair["max"])
var min_range := int(range_pair["min"])
var origin: Vector2i = unit["pos"]
for y in range(origin.y - max_range, origin.y + max_range + 1):
for x in range(origin.x - max_range, origin.x + max_range + 1):
var cell := Vector2i(x, y)

View File

@@ -13,6 +13,8 @@ const SKILL_OVERLAY_COLOR := Color(0.56, 0.28, 0.95, 0.28)
const ITEM_OVERLAY_COLOR := Color(0.24, 0.74, 0.34, 0.30)
const FORMATION_OVERLAY_COLOR := Color(0.95, 0.76, 0.25, 0.34)
const OBJECTIVE_OVERLAY_COLOR := Color(1.0, 0.78, 0.18, 0.30)
const THREAT_OVERLAY_COLOR := Color(1.0, 0.12, 0.10, 0.18)
const THREAT_BORDER_COLOR := Color(1.0, 0.16, 0.12, 0.44)
const BGM_MENU_PATH := "res://audio/bgm/menu_theme_placeholder.wav"
const BGM_BATTLE_PATH := "res://audio/bgm/battle_loop_placeholder.wav"
const SFX_BOW_RELEASE_PATH := "res://audio/sfx/bow_release_01.wav"
@@ -48,8 +50,10 @@ var move_cells: Array[Vector2i] = []
var attack_cells: Array[Vector2i] = []
var skill_cells: Array[Vector2i] = []
var item_cells: Array[Vector2i] = []
var threat_cells: Array[Vector2i] = []
var selected_skill_id := ""
var selected_item_id := ""
var show_threat_overlay := false
var active_scenario_id := ""
var battle_started := false
var battle_result_applied := false
@@ -124,6 +128,7 @@ var item_list: VBoxContainer
var equip_button: Button
var equip_menu: VBoxContainer
var equip_list: VBoxContainer
var threat_button: Button
var restart_button: Button
var new_campaign_button: Button
var log_box: RichTextLabel
@@ -280,6 +285,12 @@ func _create_hud() -> void:
equip_button.pressed.connect(_on_equip_pressed)
button_row.add_child(equip_button)
threat_button = Button.new()
threat_button.text = "Threat"
threat_button.toggle_mode = true
threat_button.pressed.connect(_on_threat_pressed)
button_row.add_child(threat_button)
end_turn_button = Button.new()
end_turn_button.text = "End Turn"
end_turn_button.pressed.connect(_on_end_turn_pressed)
@@ -875,6 +886,8 @@ func _handle_key(event: InputEventKey) -> void:
_on_item_pressed()
elif event.keycode == KEY_E and not _is_input_locked():
_on_equip_pressed()
elif event.keycode == KEY_Y and not _is_prep_menu_visible():
_on_threat_pressed()
func _process(delta: float) -> void:
@@ -932,6 +945,11 @@ func _draw_overlays() -> void:
draw_rect(objective_rect, OBJECTIVE_OVERLAY_COLOR)
draw_rect(objective_rect.grow(-5.0), Color(1.0, 0.88, 0.30, 0.82), false, 2.0)
for cell in threat_cells:
var threat_rect := _rect_for_cell(cell)
draw_rect(threat_rect, THREAT_OVERLAY_COLOR)
draw_rect(threat_rect.grow(-6.0), THREAT_BORDER_COLOR, false, 1.5)
for cell in move_cells:
draw_rect(_rect_for_cell(cell), Color(0.16, 0.55, 0.95, 0.28))
@@ -1328,6 +1346,9 @@ func _refresh_ranges() -> void:
attack_cells.clear()
skill_cells.clear()
item_cells.clear()
threat_cells.clear()
if show_threat_overlay and battle_started and state.battle_status == BattleState.STATUS_ACTIVE:
threat_cells = state.get_threat_cells(BattleState.TEAM_ENEMY)
var selected := state.get_selected_unit()
if selected.is_empty():
selected_skill_id = ""
@@ -1403,6 +1424,9 @@ func _update_hud() -> void:
end_turn_button.disabled = not state.can_player_act() or _is_input_locked()
restart_button.disabled = campaign_complete_screen
if threat_button != null:
threat_button.button_pressed = show_threat_overlay
threat_button.disabled = not battle_started or state.battle_status != BattleState.STATUS_ACTIVE or campaign_complete_screen
func _update_cell_info() -> void:
@@ -1422,6 +1446,10 @@ func _update_cell_info() -> void:
]
if state.get_objective_cells().has(cell):
text += "\nObjective marker"
if show_threat_overlay:
var threat_names := state.get_threatening_unit_names(cell, BattleState.TEAM_ENEMY)
if not threat_names.is_empty():
text += "\nThreatened by %s" % _compact_name_list(threat_names, 3)
var unit: Dictionary = summary["unit"]
if not unit.is_empty():
@@ -1448,6 +1476,16 @@ func _update_cell_info() -> void:
cell_info_label.text = text
func _compact_name_list(names: Array[String], limit: int) -> String:
var visible_names: Array[String] = []
var safe_limit: int = maxi(1, limit)
for index in range(mini(names.size(), safe_limit)):
visible_names.append(names[index])
if names.size() > safe_limit:
visible_names.append("+%d" % (names.size() - safe_limit))
return _join_strings(visible_names, ", ")
func _update_forecast() -> void:
var selected := state.get_selected_unit()
if selected.is_empty() or not state.is_inside(hover_cell):
@@ -1963,6 +2001,17 @@ func _on_equip_pressed() -> void:
queue_redraw()
func _on_threat_pressed() -> void:
if not battle_started or state.battle_status != BattleState.STATUS_ACTIVE or campaign_complete_screen:
if threat_button != null:
threat_button.button_pressed = show_threat_overlay
return
show_threat_overlay = not show_threat_overlay
_refresh_ranges()
_update_hud()
queue_redraw()
func _on_restart_pressed() -> void:
if campaign_complete_screen:
return