Add battlefield visuals and camp talk slice

This commit is contained in:
2026-06-18 16:45:46 +09:00
parent 909d497df0
commit d723d34a32
25 changed files with 604 additions and 35 deletions

View File

@@ -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")

View File

@@ -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"))

View File

@@ -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)