rimlike/scenes/ui/inspect_tooltip.gd
megaproxy ce61928a54 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>
2026-05-15 19:01:24 +01:00

356 lines
12 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)