Hover-inspect tooltip — what's under the cursor

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) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-15 19:01:24 +01:00
parent d819c13a9d
commit ce61928a54
3 changed files with 365 additions and 0 deletions

View file

@ -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

View file

@ -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)

View file

@ -0,0 +1 @@
uid://ch43c4del6vvu