rimlike/scenes/ui/inspect_tooltip.gd
megaproxy 531b907012 Inspect tooltip: add crops, bed sprite-canopy, layer audit
Player report: hovering trees works after the canopy fix, but crops show
nothing and beds still report "Wood floor" sometimes.

* Crops are added to the lookup, showing kind + growth stage + percent
  ("Wheat | sown · 98%", "Wheat | ready to harvest", "tilled — not sown").

* Bed sprite is 16×32 (two tiles tall, anchor at the foot). Hovering on
  the headboard (one tile above the anchor) used to miss. Added a sprite-
  canopy pass for beds mirroring the existing 4-tile tree canopy logic.

* Full layer audit and reordering. Final priority top-down:
    1. Pawn / Wolf / Corpse / Grave marker — entities the player cares
       about first.
    2. Crop — small sprite, exact tile only.
    3. Tree — trunk tile + 4-tile canopy.
    4. Big rock (2×2 footprint) / Rock — exact tile / footprint.
    5. Furniture at exact tile (wall / door / bed / crate / workbench /
       torch).
    6. Furniture sprite-canopy (currently only bed; future tall furniture
       slots in here).
    7. Item on the ground (loose stack).
    8. Stockpile / zone region overlay.
    9. Floor — only when nothing physical is on the tile.

Confirmed at runtime: bed foot → Bed; bed headboard → Bed (via canopy);
two-tiles-above-bed → empty; crop at growing stage shows percent; bare
floor still says Wood floor; furniture on wood floor wins over floor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:10:20 +01:00

427 lines
15 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 ──────────────────────────────────────────────────────
## 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)