extends CanvasLayer ## InspectTooltip — hover (or long-press) on a world tile to see what's there. ## ## Samples the mouse position every frame, maps to a tile, looks for an entity ## or zone at that tile, builds a short description, and renders a small panel ## offset from the cursor. Empty tile or off-map → tooltip hides. ## ## Touch fallback: long-press on a tile triggers the same lookup and pins the ## tooltip until the next click anywhere (Phase 17 long-press inspect from the ## design doc). Desktop hovering is the primary path on PC; the long-press ## path is wired via Selection's long-press detector (TODO Phase 19). const TILE_SIZE_PX: int = 16 const CURSOR_OFFSET: Vector2 = Vector2(14, 14) const EDGE_MARGIN_PX: int = 8 var _panel: PanelContainer = null var _label: RichTextLabel = null var _last_tile: Vector2i = Vector2i(-9999, -9999) var _last_text: String = "" func _ready() -> void: layer = 50 # below modals (which are 100+), above world # Process while paused so hover-inspect works during storyteller modals. process_mode = Node.PROCESS_MODE_ALWAYS _panel = PanelContainer.new() _panel.mouse_filter = Control.MOUSE_FILTER_IGNORE _panel.visible = false # Dark translucent background so text stays readable on any biome. var sb := StyleBoxFlat.new() sb.bg_color = Color(0.06, 0.08, 0.12, 0.92) sb.border_color = Color(0.55, 0.55, 0.55, 0.9) sb.border_width_left = 1 sb.border_width_top = 1 sb.border_width_right = 1 sb.border_width_bottom = 1 sb.content_margin_left = 8 sb.content_margin_right = 8 sb.content_margin_top = 6 sb.content_margin_bottom = 6 sb.corner_radius_top_left = 3 sb.corner_radius_top_right = 3 sb.corner_radius_bottom_left = 3 sb.corner_radius_bottom_right = 3 _panel.add_theme_stylebox_override("panel", sb) add_child(_panel) _label = RichTextLabel.new() _label.bbcode_enabled = true _label.fit_content = true _label.scroll_active = false _label.autowrap_mode = TextServer.AUTOWRAP_OFF _label.mouse_filter = Control.MOUSE_FILTER_IGNORE _label.custom_minimum_size = Vector2(140, 0) _label.add_theme_constant_override("line_separation", 2) _label.add_theme_color_override("default_color", Color(0.95, 0.95, 0.95, 1.0)) _panel.add_child(_label) func _process(_dt: float) -> void: var vp := get_viewport() if vp == null: return var mouse_screen: Vector2 = vp.get_mouse_position() # Convert screen → world via canvas transform (selection.gd does the same). var world_pos: Vector2 = vp.get_canvas_transform().affine_inverse() * mouse_screen var tile: Vector2i = Vector2i( floori(world_pos.x / float(TILE_SIZE_PX)), floori(world_pos.y / float(TILE_SIZE_PX)), ) # Off-map → hide. if not _tile_in_map(tile): _panel.visible = false _last_tile = Vector2i(-9999, -9999) return var text: String = _describe_tile(tile) if text == "": _panel.visible = false _last_tile = tile _last_text = "" return if text != _last_text: _label.text = text _last_text = text # Force a layout pass so size is fresh before positioning. _panel.reset_size() _last_tile = tile _panel.visible = true _position_near(mouse_screen) func _tile_in_map(tile: Vector2i) -> bool: if World.pathfinder == null: return false # Pathfinder.is_walkable checks bounds; cheap and reused. # We don't actually care about walkability — bounds-check via direct access. # Map size is exposed on the autoload after World scene _ready. var ms: Vector2i = Vector2i(80, 80) # default; refined below if available if "MAP_SIZE_TILES" in World: ms = World.MAP_SIZE_TILES return tile.x >= 0 and tile.y >= 0 and tile.x < ms.x and tile.y < ms.y func _position_near(mouse_screen: Vector2) -> void: var vp_size: Vector2 = Vector2(get_viewport().get_visible_rect().size) var sz: Vector2 = _panel.size var pos: Vector2 = mouse_screen + CURSOR_OFFSET # Flip to the other side of cursor when overflowing right / bottom edges. if pos.x + sz.x + EDGE_MARGIN_PX > vp_size.x: pos.x = mouse_screen.x - sz.x - CURSOR_OFFSET.x if pos.y + sz.y + EDGE_MARGIN_PX > vp_size.y: pos.y = mouse_screen.y - sz.y - CURSOR_OFFSET.y pos.x = clampf(pos.x, EDGE_MARGIN_PX, vp_size.x - sz.x - EDGE_MARGIN_PX) pos.y = clampf(pos.y, EDGE_MARGIN_PX, vp_size.y - sz.y - EDGE_MARGIN_PX) _panel.position = pos # ── tile → description ────────────────────────────────────────────────────── ## Priority order matches what the player visually expects on top of a tile: ## pawn > wolf > corpse > grave > crop > tree (4-tile canopy) > big rock / ## rock > furniture (bed/wall/door/crate/workbench/torch with 1-tile sprite ## canopy for tall bottom-anchored entities) > item > stockpile region > floor. ## ## Floor + terrain are the bottom layer — only shown when nothing else covers ## the tile. "Wood floor" tooltip means the tile is genuinely empty. ## ## "Sprite canopy" handles entities whose visual extends above their anchor ## tile (trees: trunk + 4 tiles up; beds: 16×32 sprite covering tile.y - 1 ## as well as tile.y). Walls / torches / crates / doors are 16×16 — anchor ## tile only, no canopy. func _describe_tile(tile: Vector2i) -> String: # 1. Pawn var p = World.pawn_at_tile(tile) if World.has_method("pawn_at_tile") else null if p != null and is_instance_valid(p): return _describe_pawn(p) # 2. Wolf for w in World.wolves: if is_instance_valid(w) and w.get("tile") == tile: return _describe_wolf(w) # 3. Corpse for c in World.corpses: if is_instance_valid(c) and c.get("tile") == tile: return _describe_corpse(c) # 4. Grave marker (permanent burial, dedicated structure) for gm in World.grave_markers: if is_instance_valid(gm) and gm.get("tile") == tile: return _describe_grave(gm) # 5. Crop (farm plant — small sprite, exact tile only) for cp in World.crops: if is_instance_valid(cp) and cp.get("tile") == tile: return _describe_crop(cp) # 6. Tree — trunk tile + 4 tiles of canopy above (sprite is 64×80 px, # anchored at trunk bottom; covers tile.y - 4..tile.y vertically). for t in World.trees: if not is_instance_valid(t): continue var tt: Vector2i = t.get("tile") if tile.x == tt.x and tile.y >= tt.y - 4 and tile.y <= tt.y: return _describe_tree(t) # 7. Rock (big rock = 2×2 footprint match; single rock = exact tile). for r in World.rocks: if not is_instance_valid(r): continue if r.has_method("footprint_tiles"): if tile in r.footprint_tiles(): return _describe_big_rock(r) elif r.get("tile") == tile: return _describe_rock(r) # 8. Furniture (build_queue) — three-pass priority: # (a) exact tile, non-floor: walls/doors/beds/crates/workbenches/torches. # (b) canopy match for tall sprites (bed = 16×32 covers tile.y - 1 too). # (c) floor + zone overlays + items below (lower layers). var floor_hit = null for s in World.build_queue: if not is_instance_valid(s): continue var s_tile: Vector2i = s.get("tile") var path: String = s.get_script().resource_path if s.get_script() else "" if s_tile == tile: if "floor.gd" in path: floor_hit = s continue var d := _describe_build_site(s) if d != "": return d # Sprite canopy pass: beds (16×32) cover the tile above their anchor. # Any other furniture taller than 16 px should be added here. for s in World.build_queue: if not is_instance_valid(s): continue var path2: String = s.get_script().resource_path if s.get_script() else "" if "bed.gd" not in path2: continue var s_tile2: Vector2i = s.get("tile") if tile.x == s_tile2.x and tile.y == s_tile2.y - 1: return _describe_build_site(s) # 9. Loose items on the ground (wood stacks, food, ingots). for it in World.items: if is_instance_valid(it) and it.get("tile") == tile: return _describe_item(it) # 10. Stockpile / graveyard / cremation zones — region overlay shown # only when nothing physical occupies the tile (items above already # win, which is what the player expects). for sp in World.stockpiles: if not is_instance_valid(sp): continue var region = sp.get("region") if region != null and region.has_point(tile): return _describe_stockpile(sp) # 11. Floor fallback (the genuinely-bare-floor case). if floor_hit != null: return _describe_build_site(floor_hit) return "" # nothing to show # ── per-entity describers ─────────────────────────────────────────────────── func _describe_pawn(p) -> String: var name_s: String = p.pawn_name var hp_s: String = "%d/100" % int(p.hp) var mood_s: String = "%d" % int(p.mood) var lines: Array[String] = [] lines.append("[b]%s[/b]" % name_s) lines.append("HP %s · Mood %s" % [hp_s, mood_s]) if p.has_method("is_downed") and p.is_downed(): lines.append("[color=red]Downed[/color]") if p.job_runner != null and p.job_runner.job != null: lines.append("[color=#aaa]%s[/color]" % p.job_runner.job.label) return "\n".join(lines) func _describe_corpse(c) -> String: var name_s: String = str(c.get("deceased_name")) var decay: float = float(c.get("decay") if "decay" in c else 0.0) var state := "fresh" if decay >= 100.0: state = "rotted" elif decay >= 50.0: state = "rotting" return "[b]Corpse of %s[/b]\n%s (%d%%)" % [name_s, state, int(decay)] func _describe_wolf(w) -> String: var state_s := "?" if w.has_method("get_state_name"): state_s = w.get_state_name() var hp_s := str(int(w.hp)) if "hp" in w else "?" return "[b]Wolf[/b]\nHP %s · %s" % [hp_s, state_s] func _describe_tree(t) -> String: var pct: int = int(100.0 * float(t.chop_progress) / float(t.CHOP_TICKS)) var designated: bool = bool(t.get("chop_designated")) var tag := " · [color=#fc6]marked[/color]" if designated else "" if pct > 0: return "[b]Tree[/b]\nChop %d%%%s" % [pct, tag] return "[b]Tree[/b]%s" % tag func _describe_rock(r) -> String: var pct: int = int(100.0 * float(r.mine_progress) / float(r.MINE_TICKS)) var designated: bool = bool(r.get("mine_designated")) var tag := " · [color=#fc6]marked[/color]" if designated else "" if pct > 0: return "[b]Rock[/b]\nMine %d%%%s" % [pct, tag] return "[b]Rock[/b]%s" % tag func _describe_big_rock(r) -> String: var pct: int = int(100.0 * float(r.mine_progress) / float(r.MINE_TICKS)) var designated: bool = bool(r.get("mine_designated")) var tag := " · [color=#fc6]marked[/color]" if designated else "" return "[b]Boulder (2×2)[/b]\nMine %d%%%s" % [pct, tag] func _describe_build_site(s) -> String: var path: String = "" if s.get_script() != null: path = s.get_script().resource_path var label: String = s.label() if s.has_method("label") else "Build site" var completed: bool = false if s.has_method("is_completed"): completed = s.is_completed() elif s.has_method("is_buildable"): completed = not s.is_buildable() if "wall.gd" in path: var mat: String = str(s.get("wall_material")) var head := "[b]%s wall[/b]" % mat.capitalize() if not completed: var prog: int = int(100.0 * float(s.build_progress) / float(s.BUILD_TICKS)) return "%s\n[color=#aaa]ghost · %d%%[/color]" % [head, prog] return head if "floor.gd" in path: var mat2: String = str(s.get("floor_material")) var head2 := "[b]%s floor[/b]" % mat2.capitalize() if not completed: return "%s\n[color=#aaa]ghost[/color]" % head2 return head2 if "door.gd" in path: var head3 := "[b]Door[/b]" if not completed: return "%s\n[color=#aaa]ghost[/color]" % head3 return head3 if "bed.gd" in path: var lines: Array[String] = [] var head4 := "Medical bed" if bool(s.get("is_medical")) else "Bed" lines.append("[b]%s[/b]" % head4) if not completed: lines.append("[color=#aaa]ghost[/color]") else: var occ = s.get("_occupant_pawn") if occ != null and is_instance_valid(occ): lines.append("Occupied by %s" % occ.pawn_name) else: lines.append("[color=#888]available[/color]") return "\n".join(lines) if "crate.gd" in path: var lines2: Array[String] = [] lines2.append("[b]Crate[/b]") if not completed: lines2.append("[color=#aaa]ghost[/color]") var contents: Array = s._contents if "_contents" in s else [] if contents.is_empty(): lines2.append("[color=#888]empty[/color]") else: var counts: Dictionary = {} for it in contents: if not is_instance_valid(it): continue var k: String = str(it.item_type) counts[k] = int(counts.get(k, 0)) + int(it.stack_size) for k in counts.keys(): lines2.append("· %s ×%d" % [k, counts[k]]) return "\n".join(lines2) if "workbench.gd" in path or "cremation_pyre.gd" in path: var lines3: Array[String] = [] lines3.append("[b]%s[/b]" % s.label_text) if not completed: lines3.append("[color=#aaa]ghost[/color]") var bills: Array = s.get("bills") if "bills" in s else [] if not bills.is_empty(): lines3.append("Bills: %d" % bills.size()) return "\n".join(lines3) if "torch.gd" in path: var head5 := "[b]Torch[/b]" if not completed: var prog2: int = int(100.0 * float(s.build_progress) / float(s.BUILD_TICKS)) return "%s\n[color=#aaa]ghost · %d%%[/color]" % [head5, prog2] var lit: bool = bool(s.is_on()) if s.has_method("is_on") else true return "%s\n%s" % [head5, ("[color=#fc6]lit[/color]" if lit else "[color=#888]unlit[/color]")] if "grave_slot.gd" in path: return "[b]Grave slot[/b]\n[color=#aaa]ghost[/color]" # Fallback for any other build_queue entity. return "[b]%s[/b]" % label func _describe_crop(cp) -> String: var kind: String = String(cp.crop_kind).capitalize() if "crop_kind" in cp else "Crop" var stage_name: String = "?" if "stage" in cp and "Stage" in cp: var keys: Array = cp.Stage.keys() var idx: int = int(cp.stage) if idx >= 0 and idx < keys.size(): stage_name = String(keys[idx]).to_lower().replace("_", " ") var pct: int = -1 if "stage_progress" in cp and "STAGE_TICKS" in cp: var st: int = int(cp.STAGE_TICKS) if cp.STAGE_TICKS != 0 else 1 pct = int(100.0 * float(cp.stage_progress) / float(st)) var head := "[b]%s[/b]" % kind if stage_name == "ready": return "%s\n[color=#fc6]ready to harvest[/color]" % head if stage_name == "tilled": return "%s\n[color=#888]tilled (not sown)[/color]" % head if pct >= 0: return "%s\n%s · %d%%" % [head, stage_name, pct] return "%s\n%s" % [head, stage_name] func _describe_item(it) -> String: return "[b]%s[/b] ×%d" % [String(it.item_type).capitalize(), int(it.stack_size)] func _describe_grave(gm) -> String: var name_s: String = str(gm.get("deceased_name")) return "[b]Grave of %s[/b]" % name_s func _describe_stockpile(sp) -> String: var label: String = str(sp.get("label")) var prio = sp.get("priority") var accepts: Array = sp.get("accepted_types") if "accepted_types" in sp else [] var lines: Array[String] = [] lines.append("[b]%s[/b]" % (label if label != "" else "Stockpile")) if prio != null: lines.append("Priority %s" % str(prio)) if accepts.is_empty(): lines.append("[color=#888]accepts: any[/color]") else: var s := "" for a in accepts: s += ("· " + String(a) + "\n") lines.append(s.strip_edges()) return "\n".join(lines)