rimlike/scenes/ui/inspect_tooltip.gd
megaproxy fd6f958344 sprint A cleanup: accessibility, signals, race, debris
G: large_text scales global theme font (14→20 at 1.4×) via new
GameState.get_font_scale + EventBus.settings_changed. reduce_motion
gates ResumeToast fade (HintOverlay already gated).

I: InspectTooltip long-press wired (500ms hold, 12px drift cancel,
tap-to-clear pin). Stale Phase 19 TODO replaced with accurate doc.

H: Pawn.arrived_at_destination now also emitted on
EventBus.pawn_arrived_at_destination; DirtinessSystem subscribes and
bumps indoor traffic dirt (BUMP_INDOOR_TRAFFIC = 0.2). Outdoor-tracked
bump needs Pawn.prev_tile — flagged for Phase 20.

P: CraftingProvider caches ingredient item ref on Job.ingredient_item;
JobRunner._tick_pickup validates is_instance_valid + not being_carried
before the tile scan, cancels cleanly if another pawn grabbed it.

J: rest_provider.gd deleted. Removed @onready + register call from
world.gd, ext_resource + node from world.tscn. Provider count comment
updated to 9.

M: DIRTY_THRESHOLD extracted — cleaning_provider and job_runner now
reference DirtinessSystem.DIRT_DIRTY_THRESHOLD.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:38:14 +01:00

516 lines
18 KiB
GDScript
Raw Permalink 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 (mouse) or long-press (touch) on a world tile to see
## what's there.
##
## Mouse path: samples position every frame, maps to a tile, builds a short
## description, renders a small panel offset from the cursor.
## Empty tile or off-map → tooltip hides.
##
## Touch path: finger held for LONG_PRESS_MS without drifting more than
## LONG_PRESS_DRIFT_PX pins the tooltip at the held tile until the next tap
## anywhere. Touch-down cancels a previous pin. This self-contained path does
## not depend on Selection; it reads the same tile + description helpers.
const TILE_SIZE_PX: int = 16
const CURSOR_OFFSET: Vector2 = Vector2(14, 14)
const EDGE_MARGIN_PX: int = 8
const LONG_PRESS_MS: int = 500 # ms finger must be held to trigger inspect
const LONG_PRESS_DRIFT_PX: float = 12.0 # cancel long-press if finger moves more than this
var _panel: PanelContainer = null
var _label: RichTextLabel = null
var _last_tile: Vector2i = Vector2i(-9999, -9999)
var _last_text: String = ""
# Touch long-press state.
var _touch_pressing: bool = false
var _touch_screen_pos: Vector2 = Vector2.ZERO
var _touch_start_ms: int = 0
var _touch_pinned: bool = false # true while a long-press pin is held open
var _touch_pin_tile: Vector2i = Vector2i(-9999, -9999)
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)
## Touch-input handler for long-press inspect.
## InputEventScreenTouch press starts the long-press clock; release or excess
## drift cancels it. A subsequent tap anywhere while pinned dismisses the panel.
func _input(event: InputEvent) -> void:
if event is InputEventScreenTouch:
if event.pressed:
if _touch_pinned:
# A new tap clears the pin.
_touch_pinned = false
_panel.visible = false
_last_text = ""
get_viewport().set_input_as_handled()
return
_touch_pressing = true
_touch_screen_pos = event.position
_touch_start_ms = Time.get_ticks_msec()
else:
# Finger lifted — cancel the pending long-press (pin only fires from _process).
_touch_pressing = false
elif event is InputEventScreenDrag and _touch_pressing:
var drift: float = event.position.distance_to(_touch_screen_pos)
if drift > LONG_PRESS_DRIFT_PX:
_touch_pressing = false
func _process(_dt: float) -> void:
var vp := get_viewport()
if vp == null:
return
# ── Touch long-press poll ─────────────────────────────────────────────────
if _touch_pressing and not _touch_pinned:
var held_ms: int = Time.get_ticks_msec() - _touch_start_ms
if held_ms >= LONG_PRESS_MS:
_touch_pressing = false
_touch_pinned = true
# Describe tile under held finger.
var world_touch: Vector2 = vp.get_canvas_transform().affine_inverse() * _touch_screen_pos
_touch_pin_tile = Vector2i(
floori(world_touch.x / float(TILE_SIZE_PX)),
floori(world_touch.y / float(TILE_SIZE_PX)),
)
if _tile_in_map(_touch_pin_tile):
var pin_text: String = _describe_tile(_touch_pin_tile)
if pin_text != "":
_label.text = pin_text
_last_text = pin_text
_panel.reset_size()
_panel.visible = true
_position_near(_touch_screen_pos)
return
# Nothing to show — cancel pin.
_touch_pinned = false
# Pinned: keep the panel up; skip the mouse-hover update.
if _touch_pinned:
return
# ── Mouse hover path ──────────────────────────────────────────────────────
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:
# Growth stage label.
var stage: int = int(t.get("growth_stage")) if "growth_stage" in t else 3
var stage_key_map: Array[StringName] = [
&"tree.stage.sapling",
&"tree.stage.young",
&"tree.stage.growing",
&"tree.stage.mature",
]
var stage_label: String = Strings.t(stage_key_map[clamp(stage, 0, 3)])
# Pending-plant ghost indicator.
var pending: bool = bool(t.get("pending_plant")) if "pending_plant" in t else false
if pending:
return "[b]%s[/b]\n[color=#aaa]awaiting pawn[/color]" % stage_label
# Growth progress for sub-mature trees.
var is_mature: bool = (stage >= 3)
if not is_mature:
var progress: int = int(t.get("growth_progress")) if "growth_progress" in t else 0
var stage_ticks: int = int(t.get("STAGE_TICKS")) if "STAGE_TICKS" in t else 1
var pct_grow: int = int(100.0 * float(progress) / float(max(stage_ticks, 1)))
return "[b]%s[/b]\nGrowing %d%%" % [stage_label, pct_grow]
# Mature tree — show chop progress if any.
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]%s[/b]\nChop %d%%%s" % [stage_label, pct, tag]
return "[b]%s[/b]%s" % [stage_label, 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)