From fd24254a7ae3e0ce5f9fc4f155d8608ea5c9c577 Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 18 Jun 2026 19:41:19 -0500 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20prologue=20wanderers=20visible=20+=20tile-dominant?= =?UTF-8?q?=20unit/city=20sprites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/game/engine/scenes/world_map/world_map.gd | 8 +- .../scenes/world_map/world_map_vision.gd | 40 +++++++++ src/game/engine/src/autoloads/log.gd | 76 +++++++++++++++++ .../engine/src/rendering/city_renderer.gd | 9 +- .../rendering/prologue_overlay_renderer.gd | 85 ++++++++++++++----- .../engine/src/rendering/unit_renderer.gd | 8 +- .../src/rendering/unit_renderer_draw.gd | 11 +++ src/game/project.godot | 1 + 8 files changed, 212 insertions(+), 26 deletions(-) create mode 100644 src/game/engine/src/autoloads/log.gd diff --git a/src/game/engine/scenes/world_map/world_map.gd b/src/game/engine/scenes/world_map/world_map.gd index 7690dbd5..73b2e966 100644 --- a/src/game/engine/scenes/world_map/world_map.gd +++ b/src/game/engine/scenes/world_map/world_map.gd @@ -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") diff --git a/src/game/engine/scenes/world_map/world_map_vision.gd b/src/game/engine/scenes/world_map/world_map_vision.gd index 8a8a530b..0417c425 100644 --- a/src/game/engine/scenes/world_map/world_map_vision.gd +++ b/src/game/engine/scenes/world_map/world_map_vision.gd @@ -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. diff --git a/src/game/engine/src/autoloads/log.gd b/src/game/engine/src/autoloads/log.gd new file mode 100644 index 00000000..4dfbcb9e --- /dev/null +++ b/src/game/engine/src/autoloads/log.gd @@ -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) diff --git a/src/game/engine/src/rendering/city_renderer.gd b/src/game/engine/src/rendering/city_renderer.gd index b16a5a1d..698b6960 100644 --- a/src/game/engine/src/rendering/city_renderer.gd +++ b/src/game/engine/src/rendering/city_renderer.gd @@ -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: diff --git a/src/game/engine/src/rendering/prologue_overlay_renderer.gd b/src/game/engine/src/rendering/prologue_overlay_renderer.gd index 002968ae..6cdd8ed6 100644 --- a/src/game/engine/src/rendering/prologue_overlay_renderer.gd +++ b/src/game/engine/src/rendering/prologue_overlay_renderer.gd @@ -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) diff --git a/src/game/engine/src/rendering/unit_renderer.gd b/src/game/engine/src/rendering/unit_renderer.gd index fee4b59a..6a494d08 100644 --- a/src/game/engine/src/rendering/unit_renderer.gd +++ b/src/game/engine/src/rendering/unit_renderer.gd @@ -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 diff --git a/src/game/engine/src/rendering/unit_renderer_draw.gd b/src/game/engine/src/rendering/unit_renderer_draw.gd index f6a93a9c..e83e2d58 100644 --- a/src/game/engine/src/rendering/unit_renderer_draw.gd +++ b/src/game/engine/src/rendering/unit_renderer_draw.gd @@ -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, diff --git a/src/game/project.godot b/src/game/project.godot index b091e509..89e22a20 100644 --- a/src/game/project.godot +++ b/src/game/project.godot @@ -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"