feat(@projects/@magic-civilization): ✨ update gameplay arc proof to use canonical entity APIs
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
5fcab0382d
commit
97f6aaea45
1 changed files with 168 additions and 135 deletions
|
|
@ -1,12 +1,15 @@
|
|||
extends Node2D
|
||||
## p2-66 follow-up — Gameplay-arc multi-shot proof.
|
||||
## p2-66 follow-up — Gameplay-arc multi-shot proof (v2).
|
||||
##
|
||||
## Drives a deterministic gameplay arc through direct `GameState` mutations
|
||||
## and captures one frame per arc step. Each step represents a real gameplay
|
||||
## moment (founding, training, moving, combat, capture, growth, borders).
|
||||
## Bypasses `TurnManager.start_turn()` because the production turn loop has
|
||||
## its own initialization order issues that aren't gameplay defects but
|
||||
## block this proof — see p2-66 status notes.
|
||||
## Drives a deterministic gameplay arc via the canonical entity APIs
|
||||
## (`City::set_population`, `City::set_hp`, `City::add_tile`, mutating
|
||||
## `placed_buildings` dict; `Unit` direct construction) and captures one
|
||||
## frame per arc step. Each step represents a real gameplay moment.
|
||||
##
|
||||
## v1 had silent failures because `city.population = N` is a read-only
|
||||
## getter on top of the Rust bridge — the assignment was dropped and
|
||||
## three subsequent frames serialised the same bytes as the prior frame.
|
||||
## v2 uses the right setter for each prop and verifies via hash dedup.
|
||||
|
||||
const MapGeneratorScript: GDScript = preload("res://engine/src/generation/map_generator.gd")
|
||||
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
|
||||
|
|
@ -27,6 +30,12 @@ var _cam: Camera2D = null
|
|||
var _game_map: RefCounted = null
|
||||
var _all_positions: Array[Vector2i] = []
|
||||
var _captured: int = 0
|
||||
var _p1: PlayerScript = null
|
||||
var _p2: PlayerScript = null
|
||||
var _c1: CityScript = null
|
||||
var _c2: CityScript = null
|
||||
var _p1_pos: Vector2i = Vector2i.ZERO
|
||||
var _p2_pos: Vector2i = Vector2i.ZERO
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
|
|
@ -58,17 +67,14 @@ func _ready() -> void:
|
|||
for pos: Vector2i in _game_map.tiles:
|
||||
_all_positions.append(pos)
|
||||
|
||||
# 2 dwarven clans, AI-flag set on both (no-op here since we drive state directly).
|
||||
for i: int in range(2):
|
||||
var player: PlayerScript = PlayerScript.new()
|
||||
player.index = i
|
||||
player.is_human = false
|
||||
player.player_name = "Clan %d" % (i + 1)
|
||||
player.race_id = "dwarf"
|
||||
player.color = Color(0.9, 0.7, 0.25) if i == 0 else Color(0.25, 0.55, 0.95)
|
||||
player.color = Color(0.95, 0.65, 0.15) if i == 0 else Color(0.20, 0.50, 0.95)
|
||||
GameState.players.append(player)
|
||||
|
||||
# Founder unit at the player's start hex.
|
||||
var start: Vector2i = Vector2i.ZERO
|
||||
if i < _game_map.start_positions.size():
|
||||
start = _game_map.start_positions[i]
|
||||
|
|
@ -76,163 +82,194 @@ func _ready() -> void:
|
|||
founder.id = "founder_%d" % i
|
||||
player.units.append(founder)
|
||||
|
||||
_p1 = GameState.players[0] as PlayerScript
|
||||
_p2 = GameState.players[1] as PlayerScript
|
||||
_p1_pos = _p1.units[0].position
|
||||
_p2_pos = _p2.units[0].position
|
||||
|
||||
_setup_renderers()
|
||||
_setup_camera_fit()
|
||||
|
||||
DirAccess.make_dir_recursive_absolute(
|
||||
ProjectSettings.globalize_path(OUTPUT_DIR)
|
||||
)
|
||||
|
||||
# ── Arc step 0: world seeded — both founders alive, no cities ──────────
|
||||
await _capture_step("01_world_seeded", "fit")
|
||||
# ── ACT I: Empire birth ────────────────────────────────────────────────
|
||||
await _capture("01_world_seeded", "fit")
|
||||
await _capture("02_founder_zoomed", "focus", _p1_pos, 6.0)
|
||||
|
||||
# ── Arc step 1: P1 founds capital at start hex ─────────────────────────
|
||||
var p1: PlayerScript = GameState.players[0] as PlayerScript
|
||||
var p2: PlayerScript = GameState.players[1] as PlayerScript
|
||||
var p1_capital_pos: Vector2i = p1.units[0].position
|
||||
p1.units.clear() # founder consumed
|
||||
var c1: CityScript = CityScript.new("clan1_capital")
|
||||
c1.owner_index = 0
|
||||
c1.position = p1_capital_pos
|
||||
if "city_name" in c1: c1.city_name = "Karak Ankor"
|
||||
if "population" in c1: c1.population = 1
|
||||
p1.cities.append(c1)
|
||||
# Re-spawn a warrior at capital so we have an inhabitant for screenshots.
|
||||
var w1: UnitScript = UnitScript.new("dwarf_warrior", 0, p1_capital_pos)
|
||||
w1.id = "p1_warrior_initial"
|
||||
p1.units.append(w1)
|
||||
await _capture_step("02_capital_founded", "focus", p1_capital_pos)
|
||||
await _capture_step("03_capital_founded_wide", "fit")
|
||||
# P1 founds capital
|
||||
_p1.units.clear()
|
||||
_c1 = CityScript.new("clan1_capital")
|
||||
_c1.owner_index = 0
|
||||
_c1.position = _p1_pos
|
||||
_c1.city_name = "Karak Ankor"
|
||||
_c1.set_population(1)
|
||||
_p1.cities.append(_c1)
|
||||
await _capture("03_p1_capital_founded", "focus", _p1_pos, 6.0)
|
||||
|
||||
# ── Arc step 2: P2 founds capital ──────────────────────────────────────
|
||||
var p2_capital_pos: Vector2i = p2.units[0].position
|
||||
p2.units.clear()
|
||||
var c2: CityScript = CityScript.new("clan2_capital")
|
||||
c2.owner_index = 1
|
||||
c2.position = p2_capital_pos
|
||||
if "city_name" in c2: c2.city_name = "Khazad Dar"
|
||||
if "population" in c2: c2.population = 1
|
||||
p2.cities.append(c2)
|
||||
var w2: UnitScript = UnitScript.new("dwarf_warrior", 1, p2_capital_pos)
|
||||
w2.id = "p2_warrior_initial"
|
||||
p2.units.append(w2)
|
||||
await _capture_step("04_two_empires", "fit")
|
||||
# P2 founds capital
|
||||
_p2.units.clear()
|
||||
_c2 = CityScript.new("clan2_capital")
|
||||
_c2.owner_index = 1
|
||||
_c2.position = _p2_pos
|
||||
_c2.city_name = "Khazad Dar"
|
||||
_c2.set_population(1)
|
||||
_p2.cities.append(_c2)
|
||||
await _capture("04_two_empires_wide", "fit")
|
||||
await _capture("05_p2_capital_zoomed", "focus", _p2_pos, 6.0)
|
||||
|
||||
# ── Arc step 3: P1 city grows to pop 5 + has 4 buildings ───────────────
|
||||
if "population" in c1:
|
||||
c1.population = 5
|
||||
# Hint to the renderer that the city is well-developed by adding
|
||||
# placed buildings into a property the renderer might draw. If the
|
||||
# field doesn't exist, harmless.
|
||||
if "buildings" in c1:
|
||||
c1.buildings = ["dwarf_granary", "dwarf_forge", "dwarf_market", "dwarf_barracks"]
|
||||
await _capture_step("05_capital_pop5_buildings", "focus", p1_capital_pos)
|
||||
# ── ACT II: Growth ─────────────────────────────────────────────────────
|
||||
# P1 grows: pop 3
|
||||
_c1.set_population(3)
|
||||
await _capture("06_capital_pop3", "focus", _p1_pos, 6.0)
|
||||
|
||||
# ── Arc step 4: train an army — 3 warriors near P1 capital ─────────────
|
||||
var neighbours: Array[Vector2i] = _hex_ring(p1_capital_pos, 1)
|
||||
# P1 grows further: pop 7
|
||||
_c1.set_population(7)
|
||||
await _capture("07_capital_pop7", "focus", _p1_pos, 6.0)
|
||||
|
||||
# Add placed buildings around the capital
|
||||
var ring1: Array[Vector2i] = _hex_ring(_p1_pos, 1)
|
||||
_c1.placed_buildings = {
|
||||
"dwarf_granary": ring1[0] if ring1.size() > 0 else _p1_pos,
|
||||
"dwarf_forge": ring1[1] if ring1.size() > 1 else _p1_pos,
|
||||
"dwarf_market": ring1[2] if ring1.size() > 2 else _p1_pos,
|
||||
"dwarf_barracks": ring1[3] if ring1.size() > 3 else _p1_pos,
|
||||
}
|
||||
await _capture("08_capital_with_buildings", "focus", _p1_pos, 7.0)
|
||||
|
||||
# ── ACT III: Borders / culture ─────────────────────────────────────────
|
||||
# Claim 1-ring around capital via add_tile
|
||||
for t: Vector2i in ring1:
|
||||
_c1.add_tile(t.x, t.y)
|
||||
await _capture("09_borders_1ring", "focus", _p1_pos, 7.0)
|
||||
|
||||
# Expand to 2 rings
|
||||
for t: Vector2i in _hex_ring(_p1_pos, 2):
|
||||
_c1.add_tile(t.x, t.y)
|
||||
await _capture("10_borders_2ring", "focus", _p1_pos, 9.0)
|
||||
|
||||
# Expand to 3 rings
|
||||
for t: Vector2i in _hex_ring(_p1_pos, 3):
|
||||
_c1.add_tile(t.x, t.y)
|
||||
await _capture("11_borders_3ring_wide", "focus", _p1_pos, 12.0)
|
||||
|
||||
# Mirror borders for P2 (smaller — 1 ring) so the wide view shows two
|
||||
# coloured culture zones.
|
||||
for t: Vector2i in _hex_ring(_p2_pos, 1):
|
||||
_c2.add_tile(t.x, t.y)
|
||||
await _capture("12_two_culture_zones_wide", "fit")
|
||||
|
||||
# ── ACT IV: Military buildup ───────────────────────────────────────────
|
||||
var spawned: int = 0
|
||||
for n_pos: Vector2i in neighbours:
|
||||
for n_pos: Vector2i in ring1:
|
||||
if not _game_map.tiles.has(n_pos):
|
||||
continue
|
||||
var tile: Resource = _game_map.tiles[n_pos]
|
||||
if tile == null:
|
||||
continue
|
||||
var is_water: bool = false
|
||||
if "biome_id" in tile and (tile.biome_id == "ocean" or tile.biome_id == "coast"):
|
||||
is_water = true
|
||||
if is_water:
|
||||
continue
|
||||
var u: UnitScript = UnitScript.new("dwarf_warrior", 0, n_pos)
|
||||
u.id = "p1_army_%d" % spawned
|
||||
p1.units.append(u)
|
||||
_p1.units.append(u)
|
||||
spawned += 1
|
||||
if spawned >= 3:
|
||||
break
|
||||
await _capture_step("06_army_assembled", "focus", p1_capital_pos)
|
||||
await _capture("13_p1_army_3warriors", "focus", _p1_pos, 7.0)
|
||||
|
||||
# ── Arc step 5: borders expand (tile-claim hint via player.owned_tiles) ──
|
||||
if "owned_tiles" in p1:
|
||||
var ring2: Array[Vector2i] = _hex_ring(p1_capital_pos, 1) + _hex_ring(p1_capital_pos, 2)
|
||||
var owned: Array = p1.owned_tiles if p1.owned_tiles is Array else []
|
||||
for t: Vector2i in ring2:
|
||||
if t not in owned:
|
||||
owned.append(t)
|
||||
p1.owned_tiles = owned
|
||||
await _capture_step("07_borders_expanded", "focus", p1_capital_pos)
|
||||
# Add a mixed army: archer + scout
|
||||
var ring2: Array[Vector2i] = _hex_ring(_p1_pos, 2)
|
||||
if ring2.size() > 1 and _game_map.tiles.has(ring2[0]):
|
||||
var arch: UnitScript = UnitScript.new("dwarf_archer", 0, ring2[0])
|
||||
arch.id = "p1_archer"
|
||||
_p1.units.append(arch)
|
||||
if ring2.size() > 2 and _game_map.tiles.has(ring2[1]):
|
||||
var sct: UnitScript = UnitScript.new("dwarf_scout", 0, ring2[1])
|
||||
sct.id = "p1_scout"
|
||||
_p1.units.append(sct)
|
||||
await _capture("14_mixed_army", "focus", _p1_pos, 9.0)
|
||||
|
||||
# ── Arc step 6: tile improvement — placeholder by mutating tile data ────
|
||||
# The hex_renderer draws indicator decorations from tile observations;
|
||||
# adding an indicator_decorations entry is the cleanest way to surface
|
||||
# an improvement in the rendered frame without touching ImprovementManager.
|
||||
var improvement_hex: Vector2i = Vector2i.ZERO
|
||||
for cand in _hex_ring(p1_capital_pos, 1):
|
||||
if _game_map.tiles.has(cand):
|
||||
improvement_hex = cand
|
||||
break
|
||||
if _game_map.tiles.has(improvement_hex):
|
||||
var tile_for_imp: Resource = _game_map.tiles[improvement_hex]
|
||||
if tile_for_imp != null and "indicator_decorations" in tile_for_imp:
|
||||
var ids: Array = tile_for_imp.indicator_decorations
|
||||
ids.append("farm")
|
||||
tile_for_imp.indicator_decorations = ids
|
||||
await _capture_step("08_improvement_placed", "focus", p1_capital_pos)
|
||||
# P2 puts up a defensive force too
|
||||
for j: int in range(2):
|
||||
var n2: Vector2i = _hex_ring(_p2_pos, 1)[j] if _hex_ring(_p2_pos, 1).size() > j else _p2_pos
|
||||
if not _game_map.tiles.has(n2):
|
||||
continue
|
||||
var u2: UnitScript = UnitScript.new("dwarf_warrior", 1, n2)
|
||||
u2.id = "p2_army_%d" % j
|
||||
_p2.units.append(u2)
|
||||
await _capture("15_p2_defenders", "focus", _p2_pos, 7.0)
|
||||
|
||||
# ── Arc step 7: army marches toward P2 — 3 warriors at midpoint ────────
|
||||
var midpoint: Vector2i = Vector2i(
|
||||
(p1_capital_pos.x + p2_capital_pos.x) / 2,
|
||||
(p1_capital_pos.y + p2_capital_pos.y) / 2,
|
||||
# ── ACT V: Campaign + combat ───────────────────────────────────────────
|
||||
# Move all P1 army units to the midpoint between capitals
|
||||
var mid: Vector2i = Vector2i(
|
||||
(_p1_pos.x + _p2_pos.x) / 2,
|
||||
(_p1_pos.y + _p2_pos.y) / 2,
|
||||
)
|
||||
for u_var: Variant in p1.units:
|
||||
var offset: int = 0
|
||||
for u_var: Variant in _p1.units:
|
||||
var u: UnitScript = u_var as UnitScript
|
||||
if u != null and u.id.begins_with("p1_army_"):
|
||||
u.position = midpoint
|
||||
midpoint = midpoint + Vector2i(1, 0)
|
||||
await _capture_step("09_army_marching", "fit")
|
||||
u.position = mid + Vector2i(offset, 0)
|
||||
offset += 1
|
||||
await _capture("16_army_marching_wide", "fit")
|
||||
await _capture("17_army_marching_zoomed", "focus", mid, 9.0)
|
||||
|
||||
# ── Arc step 8: combat — P1 unit kills P2's warrior at their capital ───
|
||||
# Move one army warrior adjacent to P2, then remove P2's defender.
|
||||
var attack_hex: Vector2i = Vector2i.ZERO
|
||||
for cand_pos: Vector2i in _hex_ring(p2_capital_pos, 1):
|
||||
if _game_map.tiles.has(cand_pos):
|
||||
attack_hex = cand_pos
|
||||
# Move attacker adjacent to P2 capital
|
||||
var attack_hex: Vector2i = _hex_ring(_p2_pos, 1)[2] if _hex_ring(_p2_pos, 1).size() > 2 else _p2_pos
|
||||
for u_var2: Variant in _p1.units:
|
||||
var u2_: UnitScript = u_var2 as UnitScript
|
||||
if u2_ != null and u2_.id == "p1_army_0":
|
||||
u2_.position = attack_hex
|
||||
break
|
||||
for u_var2: Variant in p1.units:
|
||||
var u2: UnitScript = u_var2 as UnitScript
|
||||
if u2 != null and u2.id == "p1_army_0":
|
||||
u2.position = attack_hex
|
||||
break
|
||||
# Kill P2's defender
|
||||
for j: int in range(p2.units.size() - 1, -1, -1):
|
||||
if p2.units[j].id == "p2_warrior_initial":
|
||||
p2.units.remove_at(j)
|
||||
await _capture_step("10_combat_aftermath", "focus", p2_capital_pos)
|
||||
await _capture("18_siege_approach", "focus", _p2_pos, 6.0)
|
||||
|
||||
# ── Arc step 9: P2 capital captured — ownership flips to P1 ────────────
|
||||
for j2: int in range(p2.cities.size() - 1, -1, -1):
|
||||
if p2.cities[j2].id == "clan2_capital":
|
||||
var captured_city: CityScript = p2.cities[j2] as CityScript
|
||||
captured_city.owner_index = 0
|
||||
p1.cities.append(captured_city)
|
||||
p2.cities.remove_at(j2)
|
||||
# Combat — wound the P2 city
|
||||
_c2.set_hp(35)
|
||||
await _capture("19_city_under_attack", "focus", _p2_pos, 6.0)
|
||||
|
||||
# Defender killed
|
||||
for j2: int in range(_p2.units.size() - 1, -1, -1):
|
||||
if _p2.units[j2].id.begins_with("p2_army_"):
|
||||
_p2.units.remove_at(j2)
|
||||
await _capture("20_combat_aftermath", "focus", _p2_pos, 6.0)
|
||||
|
||||
# ── ACT VI: Conquest & consolidation ───────────────────────────────────
|
||||
# Capture P2 capital — ownership flip
|
||||
for j3: int in range(_p2.cities.size() - 1, -1, -1):
|
||||
if _p2.cities[j3].id == "clan2_capital":
|
||||
var captured: CityScript = _p2.cities[j3] as CityScript
|
||||
captured.owner_index = 0
|
||||
captured.set_hp(50) # repair-on-capture
|
||||
_p1.cities.append(captured)
|
||||
_p2.cities.remove_at(j3)
|
||||
break
|
||||
await _capture_step("11_capital_captured", "focus", p2_capital_pos)
|
||||
await _capture_step("12_empire_consolidated", "fit")
|
||||
# CityRenderer caches owner_index in its dict on sync — force resync
|
||||
# by rebuilding via sync_cities (which we already do every step).
|
||||
await _capture("21_capital_captured", "focus", _p2_pos, 6.0)
|
||||
await _capture("22_empire_consolidated_wide", "fit")
|
||||
|
||||
# Found a third P1 city at the midpoint
|
||||
if _game_map.tiles.has(mid):
|
||||
var c3: CityScript = CityScript.new("clan1_outpost")
|
||||
c3.owner_index = 0
|
||||
c3.position = mid
|
||||
c3.city_name = "Karak Mid"
|
||||
c3.set_population(2)
|
||||
_p1.cities.append(c3)
|
||||
# Tiny border around it
|
||||
for t: Vector2i in _hex_ring(mid, 1):
|
||||
c3.add_tile(t.x, t.y)
|
||||
await _capture("23_three_city_empire", "fit")
|
||||
await _capture("24_outpost_zoomed", "focus", mid, 8.0)
|
||||
|
||||
# Final tableau — every unit in player colors visible
|
||||
await _capture("25_final_tableau_wide", "fit")
|
||||
|
||||
print("gameplay_arc: %d frames captured" % _captured)
|
||||
get_tree().quit()
|
||||
|
||||
|
||||
func _capture_step(label: String, mode: String, focus: Vector2i = Vector2i.ZERO) -> void:
|
||||
func _capture(label: String, mode: String, focus: Vector2i = Vector2i.ZERO, window_tiles: float = 5.0) -> void:
|
||||
if mode == "focus":
|
||||
_focus_camera(focus, 5.0 * 384.0)
|
||||
_focus_camera(focus, window_tiles * 384.0)
|
||||
else:
|
||||
_setup_camera_fit()
|
||||
_sync_renderers()
|
||||
# Force a draw cycle now so the canvas-item changes + camera transform
|
||||
# both make it into the framebuffer before we read it. Without this the
|
||||
# captured texture is a stale snapshot of the FIRST rendered frame and
|
||||
# every subsequent screenshot serialises identical bytes (verified hash
|
||||
# collision across 12 frames in an earlier run).
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
RenderingServer.force_draw(false, 0.0)
|
||||
|
|
@ -258,12 +295,10 @@ func _setup_renderers() -> void:
|
|||
_hex.render_map(_game_map)
|
||||
var empty_fog: Array[Vector2i] = []
|
||||
_hex.update_fog(_all_positions, empty_fog)
|
||||
|
||||
_units = UnitRendererScript.new()
|
||||
_units.name = "UnitRenderer"
|
||||
add_child(_units)
|
||||
_units.call("setup_visibility", 0, _game_map)
|
||||
|
||||
_cities = CityRendererScript.new()
|
||||
_cities.name = "CityRenderer"
|
||||
add_child(_cities)
|
||||
|
|
@ -320,8 +355,6 @@ func _hex_ring(centre: Vector2i, radius: int) -> Array[Vector2i]:
|
|||
if radius <= 0:
|
||||
return [centre]
|
||||
var result: Array[Vector2i] = []
|
||||
# Walk the 6 sides of the ring at the given radius using the standard
|
||||
# axial neighbour deltas. Six unit vectors for flat-top hex axial.
|
||||
var dirs: Array[Vector2i] = [
|
||||
Vector2i(1, 0), Vector2i(1, -1), Vector2i(0, -1),
|
||||
Vector2i(-1, 0), Vector2i(-1, 1), Vector2i(0, 1),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue