extends Node class_name Designation ## Phase 5 — Designation paint mode. ## ## Captures mouse input to drag-paint ghost tiles on Layer 3 (designation_layer) ## of the world TileMap. Each new cell painted emits EventBus.designation_added; ## removing a ghost emits EventBus.designation_cleared. ## ## Integrates with Selection: raises Selection.designation_active while a non-none ## tool is active so Selection does not also process those clicks. ## ## Opus wires the node into world.tscn and calls bind() + set_active_tool() from ## the build-drawer UI. # ── tool constants ─────────────────────────────────────────────────────────── const TOOL_NONE: StringName = &"none" const TOOL_BUILD_WALL: StringName = &"build_wall" const TOOL_BUILD_FLOOR: StringName = &"build_floor" const TOOL_BUILD_DOOR: StringName = &"build_door" # Phase 13 — no-roof designation: painted tiles become courtyards; RoomDetector # excludes them from auto-roofing. Calls World.toggle_no_roof_at() on apply. const TOOL_NO_ROOF: StringName = &"no_roof" # Phase 14 — graveyard zone paint: marks a region as a corpse-only storage zone. const TOOL_GRAVEYARD: StringName = &"graveyard" # Phase 14 — dig grave: queues a GraveSlot build job at the painted tile. const TOOL_DIG_GRAVE: StringName = &"dig_grave" # Phase 17 — Designate tab tools (harvest / gather orders). const TOOL_CHOP: StringName = &"chop" const TOOL_MINE: StringName = &"mine" # Phase 17 — Build tab single-entity tools. const TOOL_BUILD_CRATE: StringName = &"build_crate" const TOOL_BUILD_BED: StringName = &"build_bed" const TOOL_BUILD_TORCH: StringName = &"build_torch" # Phase 17 — Build tab workbench variants (one tool per bench kind so the # build-drawer can display each as a distinct button with a distinct label). const TOOL_BUILD_WORKBENCH_CARPENTER: StringName = &"build_workbench_carpenter" const TOOL_BUILD_WORKBENCH_SMELTER: StringName = &"build_workbench_smelter" const TOOL_BUILD_WORKBENCH_MILLSTONE: StringName = &"build_workbench_millstone" const TOOL_BUILD_WORKBENCH_HEARTH: StringName = &"build_workbench_hearth" const TOOL_BUILD_WORKBENCH_CREMATION_PYRE: StringName = &"build_workbench_cremation_pyre" # Phase 17 — Stockpile tab. const TOOL_PAINT_STOCKPILE: StringName = &"paint_stockpile" # ── tool → material override ───────────────────────────────────────────────── # For build_wall and build_floor the tool is shared but the material differs. # The build-drawer sets _tool_material before activating the tool so the spawn # bridge in world.gd can read it. Default is "stone" for walls, "wood" for # floors — matching the Phase 5 demo seeds. var tool_material: StringName = &"" # set by build_drawer; read by world.gd dispatch # Atlas coords on the shared placeholder tileset (source 0). # build_wall → stone-grey (2, 0); build_floor → dirt-brown (1, 0). # build_door → dark stone (3, 0) so the ghost reads visually distinct from walls. # no_roof → grass (0, 0) with the designation layer modulate tinting it visibly. # graveyard / dig_grave → dirt-brown (1, 0); modulate tints these dark brown. # chop / mine / build_crate / build_bed / build_torch → reuse existing atlas slots. # workbench variants and paint_stockpile → dirt-brown (1, 0) ghost. const _ATLAS_BY_TOOL: Dictionary = { &"build_wall": Vector2i(2, 0), &"build_floor": Vector2i(1, 0), &"build_door": Vector2i(3, 0), &"no_roof": Vector2i(0, 0), &"graveyard": Vector2i(1, 0), &"dig_grave": Vector2i(1, 0), &"chop": Vector2i(0, 0), &"mine": Vector2i(2, 0), &"build_crate": Vector2i(1, 0), &"build_bed": Vector2i(1, 0), &"build_torch": Vector2i(1, 0), &"build_workbench_carpenter": Vector2i(1, 0), &"build_workbench_smelter": Vector2i(2, 0), &"build_workbench_millstone": Vector2i(1, 0), &"build_workbench_hearth": Vector2i(1, 0), &"build_workbench_cremation_pyre":Vector2i(3, 0), &"paint_stockpile": Vector2i(0, 0), } # Placeholder source ID — mirrors World.PLACEHOLDER_SOURCE_ID. const _SOURCE_ID: int = 0 # Tile-size in pixels — mirrors World.TILE_SIZE_PX. const _TILE_SIZE_PX: float = 16.0 # ── signals ────────────────────────────────────────────────────────────────── signal designation_added(cell: Vector2i, tool: StringName) signal designation_cleared(cell: Vector2i) # ── state ──────────────────────────────────────────────────────────────────── var _tool: StringName = TOOL_NONE var _paint_layer: TileMapLayer = null var _selection: Selection = null # optional; raised/lowered for input hand-off # cell → tool that placed the ghost. var _painted: Dictionary = {} # Dictionary[Vector2i, StringName] # Stroke deduplication — cells touched in the current mouse-down session. var _stroke_cells: Dictionary = {} # Dictionary[Vector2i, bool] var _stroke_active: bool = false # ── public API ─────────────────────────────────────────────────────────────── ## Call once from World._ready() with the TileMapLayer at index 3 (Designation) ## and the sibling Selection node. func bind(paint_layer: TileMapLayer, selection: Selection = null) -> void: assert(paint_layer != null, "Designation.bind: paint_layer is null") _paint_layer = paint_layer _selection = selection ## Activate a paint tool. Pass TOOL_NONE to deactivate. func set_active_tool(tool: StringName) -> void: assert( tool in [ TOOL_NONE, TOOL_BUILD_WALL, TOOL_BUILD_FLOOR, TOOL_BUILD_DOOR, TOOL_NO_ROOF, TOOL_GRAVEYARD, TOOL_DIG_GRAVE, TOOL_CHOP, TOOL_MINE, TOOL_BUILD_CRATE, TOOL_BUILD_BED, TOOL_BUILD_TORCH, TOOL_BUILD_WORKBENCH_CARPENTER, TOOL_BUILD_WORKBENCH_SMELTER, TOOL_BUILD_WORKBENCH_MILLSTONE, TOOL_BUILD_WORKBENCH_HEARTH, TOOL_BUILD_WORKBENCH_CREMATION_PYRE, TOOL_PAINT_STOCKPILE, ], "Designation.set_active_tool: unknown tool '%s'" % tool ) _tool = tool _stroke_active = false _stroke_cells.clear() _sync_selection_flag() if tool == TOOL_NONE: Audit.log("designation", "tool deactivated") else: Audit.log("designation", "tool set to '%s'" % tool) func active_tool() -> StringName: return _tool ## Remove the ghost for a single cell and emit designation_cleared. func clear_cell(cell: Vector2i) -> void: if not _painted.has(cell): return _painted.erase(cell) if _paint_layer != null: _paint_layer.erase_cell(cell) designation_cleared.emit(cell) EventBus.designation_cleared.emit(cell) Audit.log("designation", "cleared cell %s" % cell) ## Returns all cells that currently have an active ghost. func cells() -> Array[Vector2i]: var result: Array[Vector2i] = [] for c: Vector2i in _painted.keys(): result.append(c) return result # ── input ──────────────────────────────────────────────────────────────────── ## _input runs before _unhandled_input so Escape / right-click tool-cancel takes ## priority over Selection's deselect and over panel close-handlers. func _input(event: InputEvent) -> void: if _tool == TOOL_NONE: return # Escape — cancel the active tool; consume so lower handlers don't also fire. if event.is_action_pressed("cancel"): set_active_tool(TOOL_NONE) get_viewport().set_input_as_handled() Audit.log("designation", "escape: tool cancelled") return # Right-click — also cancel the active tool (RTS convention). if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT and event.pressed: set_active_tool(TOOL_NONE) get_viewport().set_input_as_handled() Audit.log("designation", "right-click: tool cancelled") return func _unhandled_input(event: InputEvent) -> void: if _tool == TOOL_NONE or _paint_layer == null: return if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: if event.pressed: _stroke_active = true _stroke_cells.clear() _paint_at_screen(event.position) else: _stroke_active = false _stroke_cells.clear() get_viewport().set_input_as_handled() return if event is InputEventMouseMotion and _stroke_active: _paint_at_screen(event.position) get_viewport().set_input_as_handled() # ── helpers ────────────────────────────────────────────────────────────────── func _paint_at_screen(screen_pos: Vector2) -> void: var world_pos: Vector2 = get_viewport().get_canvas_transform().affine_inverse() * screen_pos var cell := Vector2i( floori(world_pos.x / _TILE_SIZE_PX), floori(world_pos.y / _TILE_SIZE_PX), ) # Deduplication within the current stroke. if _stroke_cells.has(cell): return _stroke_cells[cell] = true # Don't re-paint the same tool over itself. if _painted.get(cell) == _tool: return _apply_ghost(cell) func _apply_ghost(cell: Vector2i) -> void: var atlas_coord: Vector2i = _ATLAS_BY_TOOL.get(_tool, Vector2i(2, 0)) _paint_layer.set_cell(cell, _SOURCE_ID, atlas_coord) _painted[cell] = _tool # Modulate the whole paint layer based on whether this cell is placeable. # Phase 5 simplification: validity applies globally to the layer. var ok: bool = _cell_is_placeable(cell) _paint_layer.modulate = Color(0.4, 1.0, 0.4, 0.7) if ok else Color(1.0, 0.4, 0.4, 0.7) # Phase 13 — no-roof tool: toggle the tile in World.no_roof_cells and # trigger an immediate room recompute. No build job is queued. if _tool == TOOL_NO_ROOF: World.toggle_no_roof_at(cell) Audit.log("designation", "no_roof toggled at %s" % cell) return designation_added.emit(cell, _tool) EventBus.designation_added.emit(cell, _tool) Audit.log("designation", "painted %s at %s (placeable=%s)" % [_tool, cell, ok]) func _cell_is_placeable(cell: Vector2i) -> bool: # For Phase 5: a cell is placeable for build_wall if it is currently walkable # (no wall present), and for build_floor if it is walkable. Items-on-tile # check is deferred to Phase 5 BuildJob validation; we only gate on geometry here. if World.pathfinder == null: return true return World.pathfinder.is_walkable(cell) func _sync_selection_flag() -> void: if _selection == null: return _selection.designation_active = (_tool != TOOL_NONE)