feat(@projects/@magic-civilization): prologue wanderers visible + tile-dominant unit/city sprites

- Reveal the prologue spawn box in recalculate_vision: the player owns no units
  during the turn -1/0/1 cold-open, so unit-derived fog left the whole map black
  and the wanderers invisible. Reveal the local player's box (LOS from centroid +
  each wanderer hex) whenever a prologue is active.
- Prologue overlay: draw the real dwarf_wanderer / dwarf_tribe sprites, centered
  on the hex (+ hex_center, was drawing at the corner), scaled to match in-game
  units. Marker glyph kept only as a missing-asset fallback.
- Scale all unit sprites to 0.6 of hex width and city sprites to 0.75 via
  DrawHelpers.scaled_sprite_size — Civ-like tile dominance instead of the tiny
  native 64px blit that read as unreadable tokens.
- Add toggleable leveled Log autoload (dev/info/warn/error via MC_LOG_LEVEL,
  default info); route prologue diagnostics through it with durable tags
  (prologue / prologue-overlay), not objective ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-18 19:41:19 -05:00
parent 74c0868059
commit fd24254a7a
8 changed files with 212 additions and 26 deletions

View file

@ -322,7 +322,7 @@ func _start_game() -> void:
# `get_data("setup")` returns the per-id dict which is empty for this
# file shape — caught the hard way in the first apricot smoke run.
var start_turn: int = _read_start_turn_from_setup()
print("[p0-34] world_map._start_game: start_turn=%d" % start_turn)
Log.info("world_map._start_game: start_turn=%d" % start_turn, "prologue")
if start_turn == -1:
_bootstrap_prologue(game_map)
else:
@ -428,8 +428,8 @@ func _bootstrap_prologue(game_map: RefCounted) -> void:
var mode: String = _read_prologue_mode_from_setup()
var radius: int = _read_spawn_box_radius_from_setup()
print("[p0-34] _bootstrap_prologue: mode=%s radius=%d players=%d" %
[mode, radius, GameState.players.size()])
Log.info("_bootstrap_prologue: mode=%s radius=%d players=%d" %
[mode, radius, GameState.players.size()], "prologue")
var registered_count: int = 0
for p: Variant in GameState.players:
@ -446,6 +446,8 @@ func _bootstrap_prologue(game_map: RefCounted) -> void:
pid, start_pos.x, start_pos.y, mode, radius, grid
):
registered_count += 1
var wflat: PackedInt32Array = (driver as PrologueDriverScript).wanderers_for(pid)
Log.dev("player %d registered: wanderers=%d" % [pid, wflat.size() / 2], "prologue")
if registered_count == 0:
push_warning("WorldMap: no players registered with prologue; reverting to legacy")

View file

@ -21,6 +21,9 @@ extends RefCounted
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
const PathfinderScript: GDScript = preload("res://engine/src/map/pathfinder.gd")
const PrologueDriverScript: GDScript = preload(
"res://engine/src/modules/management/prologue_driver.gd"
)
## Tri-state visibility values stored in tile.visibility[player_index].
const VIS_UNSEEN: int = 0 ## Never seen — full black fog.
@ -33,6 +36,10 @@ const SIGHT_RANGE_SCOUT: int = 3 ## Fast explorer — sees 3 hexes out.
const SIGHT_RANGE_WARRIOR: int = 2 ## Standard military — 2-hex awareness.
const SIGHT_RANGE_CITY: int = 2 ## Cities act as watchtowers at same range.
## p0-34: how far around the spawn-box centroid to lift fog during the
## tribe-founding prologue. Box radius defaults to 3; +1 margin shows its edge.
const PROLOGUE_REVEAL_RANGE: int = 4
static func recalculate_vision(player: RefCounted, game_map: RefCounted) -> void:
## Reset all tiles from visible(2) to explored(1), then recompute from units.
@ -53,6 +60,39 @@ static func recalculate_vision(player: RefCounted, game_map: RefCounted) -> void
if tile != null:
tile.set_visibility(player.index, 2)
# p0-34: during the tribe-founding prologue the player owns no units, so the
# loop above leaves the whole map fogged and the wanderer glyphs invisible.
# Reveal the player's spawn box directly so the opening is visible (design:
# "fog partially lifted so the player sees the whole box").
if TurnManager.prologue != null and TurnManager.prologue.is_available():
_reveal_prologue_box(player, game_map)
## p0-34: reveal the local player's prologue spawn box — LOS from the centroid
## plus each wanderer's exact hex (LOS-independent, so no glyph hides behind
## blocking terrain). No-op once the prologue clears (centroid == ZERO).
static func _reveal_prologue_box(player: RefCounted, game_map: RefCounted) -> void:
var driver: PrologueDriverScript = TurnManager.prologue as PrologueDriverScript
if driver == null:
return
var pid: int = int(player.index)
var centroid: Vector2i = driver.centroid(pid)
if centroid != Vector2i.ZERO:
var box: Array[Vector2i] = PathfinderScript.visible_hexes(
game_map, centroid, PROLOGUE_REVEAL_RANGE
)
for pos: Vector2i in box:
var tile: Resource = game_map.get_tile(pos) as Resource
if tile != null:
tile.set_visibility(player.index, VIS_VISIBLE)
var flat: PackedInt32Array = driver.wanderers_for(pid)
var i: int = 0
while i + 1 < flat.size():
var wtile: Resource = game_map.get_tile(Vector2i(flat[i], flat[i + 1])) as Resource
if wtile != null:
wtile.set_visibility(player.index, VIS_VISIBLE)
i += 2
static func record_observations(player: RefCounted, game_map: RefCounted) -> void:
## Record currently-visible tiles into the observation store for climate history.

View file

@ -0,0 +1,76 @@
extends Node
## Leveled, toggleable console logger. Severity order: DEV < INFO < WARN < ERROR.
## A message emits only when its level is >= the active threshold, which is read
## from the `MC_LOG_LEVEL` env var (dev|info|warn|error; default "info") at boot.
## DEV is silent in normal runs and turned on with `MC_LOG_LEVEL=dev` for
## debugging; flip back by unsetting the var.
##
## WARN routes through push_warning and ERROR through push_error so they also
## surface in Godot's error stream and CI logs; DEV and INFO print to stdout.
##
## Distinct from GameLogger: that is the structured flight-recorder event stream
## (HTTP batches, typed events). This is plain severity-leveled console logging
## for development and diagnostics.
enum Level { DEV = 0, INFO = 1, WARN = 2, ERROR = 3 }
const LEVEL_NAMES: Dictionary = {
Level.DEV: "DEV",
Level.INFO: "INFO",
Level.WARN: "WARN",
Level.ERROR: "ERROR",
}
const _NAME_TO_LEVEL: Dictionary = {
"dev": Level.DEV,
"info": Level.INFO,
"warn": Level.WARN,
"error": Level.ERROR,
}
var _threshold: int = Level.INFO
func _ready() -> void:
var configured: String = EnvConfig.get_var("MC_LOG_LEVEL", "info").to_lower()
if _NAME_TO_LEVEL.has(configured):
_threshold = _NAME_TO_LEVEL[configured]
else:
push_warning("Log: unknown MC_LOG_LEVEL '%s' — defaulting to info" % configured)
## Override the threshold at runtime (e.g. from a debug console).
func set_threshold(level: Level) -> void:
_threshold = level
func dev(message: String, tag: String = "") -> void:
_emit(Level.DEV, message, tag)
func info(message: String, tag: String = "") -> void:
_emit(Level.INFO, message, tag)
func warn(message: String, tag: String = "") -> void:
_emit(Level.WARN, message, tag)
func error(message: String, tag: String = "") -> void:
_emit(Level.ERROR, message, tag)
func _emit(level: Level, message: String, tag: String) -> void:
if level < _threshold:
return
var prefix: String = "[%s]" % LEVEL_NAMES[level]
if not tag.is_empty():
prefix += "[%s]" % tag
var line: String = "%s %s" % [prefix, message]
match level:
Level.WARN:
push_warning(line)
Level.ERROR:
push_error(line)
_:
print(line)

View file

@ -6,6 +6,11 @@ extends Node2D
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
const CityScript: GDScript = preload("res://engine/src/entities/city.gd")
const DrawHelpers: GDScript = preload("res://engine/src/rendering/unit_renderer_draw.gd")
## City sprite size as a fraction of hex width (aspect preserved). Cities are the
## tile's focal structure, so slightly larger than units (UNIT_SPRITE_TILE_FRACTION).
const CITY_SPRITE_TILE_FRACTION: float = 0.75
## Visual constants
const CITY_RADIUS: float = 24.0
@ -157,8 +162,8 @@ func _draw_city_sprite(pixel: Vector2, city: CityScript, color: Color) -> void:
# Optional overlay: draw sprite on top when available
var sprite: Texture2D = _get_city_sprite(city)
if sprite != null:
var tex_size: Vector2 = sprite.get_size()
draw_texture(sprite, pixel - tex_size * 0.5)
var draw_size: Vector2 = DrawHelpers.scaled_sprite_size(sprite, CITY_SPRITE_TILE_FRACTION)
draw_texture_rect(sprite, Rect2(pixel - draw_size * 0.5, draw_size), false)
func _draw_city_label(pixel: Vector2, city: CityScript) -> void:

View file

@ -1,9 +1,9 @@
class_name PrologueOverlayRenderer
extends Node2D
## Draw-first overlay for the Freepeople tribe-founding prologue (p0-34).
## Paints a circle + letter glyph for each wanderer ('W') and for the
## convergence centroid (when relevant). Matches the placeholder pattern
## introduced by p0-23 ahead of asset-sprite's real sprite authoring.
## Draw-first overlay for the Freepeople tribe-founding prologue.
## Paints the real dwarf-wanderer sprite for each wanderer and the dwarf-tribe
## sprite at the convergence centroid, scaled to match in-game units. Falls back
## to a marker glyph (circle + letter) only when a sprite asset is missing.
##
## Reads state from TurnManager.prologue (a PrologueDriver). Hidden when
## the prologue has resolved (state == Normal) or when the driver is null.
@ -12,29 +12,48 @@ const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
const PrologueDriverScript: GDScript = preload(
"res://engine/src/modules/management/prologue_driver.gd"
)
const DrawHelpers: GDScript = preload("res://engine/src/rendering/unit_renderer_draw.gd")
const WANDERER_RADIUS: float = 10.0
const TRIBE_RADIUS: float = 16.0
const WANDERER_COLOR: Color = Color(0.85, 0.75, 0.55, 0.9)
const WANDERER_RADIUS: float = 24.0
const TRIBE_RADIUS: float = 30.0
const WANDERER_COLOR: Color = Color(0.95, 0.80, 0.40, 0.95)
## Real wanderer sprite (copyleft asset); the marker glyph below is only the
## missing-asset fallback.
const WANDERER_SPRITE_PATH: String = "sprites/units/dwarf_wanderer.png"
const TRIBE_SPRITE_PATH: String = "sprites/units/dwarf_tribe.png"
## Sprite size as a fraction of hex width — mirror of unit_renderer.gd
## UNIT_SPRITE_TILE_FRACTION so prologue units match in-game units. Keep in sync.
const SPRITE_TILE_FRACTION: float = 0.6
const WANDERER_BORDER: Color = Color(0.25, 0.18, 0.08, 1.0)
const TRIBE_COLOR: Color = Color(0.9, 0.5, 0.2, 0.95)
const TRIBE_BORDER: Color = Color(0.2, 0.12, 0.05, 1.0)
const GLYPH_COLOR: Color = Color.BLACK
var _font: Font = null
var _wanderer_tex: Texture2D = null
var _tribe_tex: Texture2D = null
func _ready() -> void:
z_index = 12 # above hex + fog, below modal overlays
_font = ThemeDB.fallback_font
_wanderer_tex = ThemeAssets.load_sprite(WANDERER_SPRITE_PATH, false)
_tribe_tex = ThemeAssets.load_sprite(TRIBE_SPRITE_PATH, false)
func _draw() -> void:
if TurnManager.prologue == null:
Log.dev("overlay _draw skipped: TurnManager.prologue is null", "prologue-overlay")
return
var driver: PrologueDriverScript = TurnManager.prologue as PrologueDriverScript
if not driver.is_available():
Log.dev("overlay _draw skipped: driver not available", "prologue-overlay")
return
Log.dev(
"overlay _draw running: players=%d visible=%s global_pos=%s"
% [GameState.players.size(), str(visible), str(global_position)],
"prologue-overlay",
)
# Drawing happens even on Normal so the capital-founded transition looks
# clean (empty overlay). Wanderers + tribes are queried per known player.
for player_var: Variant in GameState.players:
@ -47,16 +66,31 @@ func _draw() -> void:
func _draw_wanderers(driver: PrologueDriverScript, player_id: int) -> void:
var flat: PackedInt32Array = driver.wanderers_for(player_id)
if flat.size() >= 2:
var first_px: Vector2 = HexUtilsScript.axial_to_pixel(Vector2i(flat[0], flat[1]))
Log.dev(
"draw_wanderers p%d: count=%d first axial=(%d,%d) px=%s"
% [player_id, flat.size() / 2, flat[0], flat[1], str(first_px)],
"prologue-overlay",
)
else:
Log.dev("draw_wanderers p%d: count=0 (empty)" % player_id, "prologue-overlay")
var i: int = 0
while i + 1 < flat.size():
var q: int = flat[i]
var r: int = flat[i + 1]
var pixel: Vector2 = HexUtilsScript.axial_to_pixel(Vector2i(q, r))
draw_circle(pixel, WANDERER_RADIUS, WANDERER_COLOR)
draw_arc(pixel, WANDERER_RADIUS, 0.0, TAU, 24, WANDERER_BORDER, 1.5)
if _font != null:
var offset: Vector2 = Vector2(-4.0, 5.0)
draw_string(_font, pixel + offset, "W", HORIZONTAL_ALIGNMENT_LEFT, -1, 14, GLYPH_COLOR)
var pixel: Vector2 = (
HexUtilsScript.axial_to_pixel(Vector2i(q, r)) + HexUtilsScript.hex_center
)
if _wanderer_tex != null:
_draw_unit_sprite(_wanderer_tex, pixel)
else:
# Missing-asset fallback: the placeholder marker glyph.
draw_circle(pixel, WANDERER_RADIUS, WANDERER_COLOR)
draw_arc(pixel, WANDERER_RADIUS, 0.0, TAU, 24, WANDERER_BORDER, 3.0)
if _font != null:
var offset: Vector2 = Vector2(-7.0, 7.0)
draw_string(_font, pixel + offset, "W", HORIZONTAL_ALIGNMENT_LEFT, -1, 20, GLYPH_COLOR)
i += 2
@ -66,9 +100,22 @@ func _draw_tribe_marker(driver: PrologueDriverScript, player_id: int) -> void:
return
var q: int = int(tribe.get("q", 0))
var r: int = int(tribe.get("r", 0))
var pixel: Vector2 = HexUtilsScript.axial_to_pixel(Vector2i(q, r))
draw_circle(pixel, TRIBE_RADIUS, TRIBE_COLOR)
draw_arc(pixel, TRIBE_RADIUS, 0.0, TAU, 28, TRIBE_BORDER, 2.0)
if _font != null:
var offset: Vector2 = Vector2(-5.0, 6.0)
draw_string(_font, pixel + offset, "T", HORIZONTAL_ALIGNMENT_LEFT, -1, 18, GLYPH_COLOR)
var pixel: Vector2 = (
HexUtilsScript.axial_to_pixel(Vector2i(q, r)) + HexUtilsScript.hex_center
)
if _tribe_tex != null:
_draw_unit_sprite(_tribe_tex, pixel)
else:
# Missing-asset fallback: the placeholder marker glyph.
draw_circle(pixel, TRIBE_RADIUS, TRIBE_COLOR)
draw_arc(pixel, TRIBE_RADIUS, 0.0, TAU, 28, TRIBE_BORDER, 3.0)
if _font != null:
var offset: Vector2 = Vector2(-8.0, 8.0)
draw_string(_font, pixel + offset, "T", HORIZONTAL_ALIGNMENT_LEFT, -1, 24, GLYPH_COLOR)
## Draw a unit sprite scaled to SPRITE_TILE_FRACTION of the hex width (aspect
## preserved), centered on `pixel` — matches unit_renderer's in-game unit size.
func _draw_unit_sprite(tex: Texture2D, pixel: Vector2) -> void:
var draw_size: Vector2 = DrawHelpers.scaled_sprite_size(tex, SPRITE_TILE_FRACTION)
draw_texture_rect(tex, Rect2(pixel - draw_size * 0.5, draw_size), false)

View file

@ -59,6 +59,10 @@ const COMBAT_TYPE_LABELS: Dictionary = {
const SPRITE_LOOKUP_RACE_SEX_FORMAT: String = "sprites/units/%s_%s_%s.png"
const SPRITE_LOOKUP_GENERIC_FORMAT: String = "sprites/units/%s.png"
## Unit sprite size as a fraction of hex width (aspect preserved) — tile-dominant
## per 4X convention. Mirror of prologue_overlay_renderer.gd SPRITE_TILE_FRACTION.
const UNIT_SPRITE_TILE_FRACTION: float = 0.6
## Formation outline colors and geometry.
const FORMATION_LINE_COLOR: Color = Color(0.85, 0.75, 0.25, 0.6)
const FORMATION_LINE_WIDTH: float = 3.0
@ -288,8 +292,8 @@ func _draw() -> void:
var sex: String = data.get("sex", "")
var sprite: Texture2D = _get_unit_sprite(type_id, race_id, sex)
if sprite != null:
var tex_size: Vector2 = sprite.get_size()
draw_texture(sprite, pixel - tex_size * 0.5)
var draw_size: Vector2 = DrawHelpers.scaled_sprite_size(sprite, UNIT_SPRITE_TILE_FRACTION)
draw_texture_rect(sprite, Rect2(pixel - draw_size * 0.5, draw_size), false)
DrawHelpers.draw_hp_bar(
self, pixel, data, HP_BAR_WIDTH, HP_BAR_HEIGHT, HP_BAR_OFFSET_Y, HP_BAR_BG_COLOR

View file

@ -4,6 +4,17 @@ extends RefCounted
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
## Scale a unit sprite to `fraction` of the hex width, preserving aspect ratio.
## Source sprites are small (64px); this upsizes them to a tile-dominant size so
## units read clearly (4X convention) instead of as tiny tokens.
static func scaled_sprite_size(sprite: Texture2D, fraction: float) -> Vector2:
var tex: Vector2 = sprite.get_size()
var target_w: float = HexUtilsScript.HEX_WIDTH * fraction
if tex.x <= 0.0:
return Vector2(target_w, target_w)
return tex * (target_w / tex.x)
## Draws formation outlines between adjacent member units above movement range.
static func draw_formation_outlines(
canvas: CanvasItem,

View file

@ -14,6 +14,7 @@ config/features=PackedStringArray("4.3", "GL Compatibility")
[autoload]
EnvConfig="*res://engine/src/autoloads/env_config.gd"
Log="*res://engine/src/autoloads/log.gd"
SettingsManager="*res://engine/src/autoloads/settings_manager.gd"
EventBus="*res://engine/src/autoloads/event_bus.gd"
DataLoader="*res://engine/src/autoloads/data_loader.gd"