From ce61928a54e808d631147304e59fe2dce3a16e47 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Fri, 15 May 2026 19:01:24 +0100 Subject: [PATCH] =?UTF-8?q?Hover-inspect=20tooltip=20=E2=80=94=20what's=20?= =?UTF-8?q?under=20the=20cursor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an InspectTooltip CanvasLayer that follows the mouse, samples the tile under the cursor each frame, and renders a small dark panel with a short description of whatever's there. Per-entity describers cover the playable surface: * Pawn: name + HP + mood + current job * Tree / rock / big rock: progress %, "marked" tag if designated * Wall: material + ghost/% if unbuilt * Floor / door / torch: ghost vs complete state * Bed: occupant or "available", medical tag * Crate: full contents broken down by item type and count * Workbench: label + active bills count * Item on ground: type + stack size * Corpse: deceased name + fresh/rotting/rotted state * Wolf: HP + state * Grave marker: deceased name * Stockpile / graveyard zone: name + priority + accepted types Layer 50 so the tooltip sits above the world but below modals (which sit at 100+). process_mode = ALWAYS so hovering still works during storyteller modals. Position auto-flips to the other side of the cursor when it would overflow the viewport. Co-Authored-By: Claude Opus 4.7 (1M context) --- scenes/main/main.gd | 8 + scenes/ui/inspect_tooltip.gd | 356 +++++++++++++++++++++++++++++++ scenes/ui/inspect_tooltip.gd.uid | 1 + 3 files changed, 365 insertions(+) create mode 100644 scenes/ui/inspect_tooltip.gd create mode 100644 scenes/ui/inspect_tooltip.gd.uid diff --git a/scenes/main/main.gd b/scenes/main/main.gd index d5f3f36..692a27d 100644 --- a/scenes/main/main.gd +++ b/scenes/main/main.gd @@ -132,6 +132,14 @@ func _ready() -> void: alerts_log.name = "AlertsLog" add_child(alerts_log) + # Inspect tooltip — hover any tile to see what's there (pawn / wall / bed + # / crate contents / tree progress / item stack / stockpile filter). + # Layer 50: above world, below modals (which sit at 100+). + var inspect := CanvasLayer.new() + inspect.set_script(preload("res://scenes/ui/inspect_tooltip.gd")) + inspect.name = "InspectTooltip" + add_child(inspect) + # Inject refs into TopBar and add Work + Log buttons to ButtonRow. if top_bar != null: top_bar.work_priority_matrix = work_matrix diff --git a/scenes/ui/inspect_tooltip.gd b/scenes/ui/inspect_tooltip.gd new file mode 100644 index 0000000..a369953 --- /dev/null +++ b/scenes/ui/inspect_tooltip.gd @@ -0,0 +1,356 @@ +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 ────────────────────────────────────────────────────── + +func _describe_tile(tile: Vector2i) -> String: + # Priority order: pawn > corpse > wolf > big_rock > tree > rock > furniture + # (wall/door/bed/crate/workbench/torch/floor) > item > stockpile zone > terrain. + 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) + + for c in World.corpses: + if is_instance_valid(c) and c.get("tile") == tile: + return _describe_corpse(c) + + for w in World.wolves: + if is_instance_valid(w) and w.get("tile") == tile: + return _describe_wolf(w) + + 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) + + for t in World.trees: + if is_instance_valid(t) and t.get("tile") == tile: + return _describe_tree(t) + + # Furniture / build sites at this tile (Wall, Floor, Door, Bed, Crate, + # Workbench, Torch, GraveSlot all live in World.build_queue via _ready). + for s in World.build_queue: + if not is_instance_valid(s): + continue + if s.get("tile") == tile: + var d := _describe_build_site(s) + if d != "": + return d + + # Items lying on the ground. + for it in World.items: + if is_instance_valid(it) and it.get("tile") == tile: + return _describe_item(it) + + # Grave markers (permanent, not in build_queue once converted). + for gm in World.grave_markers: + if is_instance_valid(gm) and gm.get("tile") == tile: + return _describe_grave(gm) + + # Stockpile / graveyard / cremation zones (region-based, may be multi-cell). + 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) + + 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_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) diff --git a/scenes/ui/inspect_tooltip.gd.uid b/scenes/ui/inspect_tooltip.gd.uid new file mode 100644 index 0000000..a371f7a --- /dev/null +++ b/scenes/ui/inspect_tooltip.gd.uid @@ -0,0 +1 @@ +uid://ch43c4del6vvu