rimlike/scenes/ui/inspect_tooltip.gd
megaproxy d98d2c2425 Renewable resources: tree growth + WildGrowth + Quarry on BigRockNode
Trees: 4 growth stages (Sapling→Young→Growing→Mature), only Mature
yields wood. WildGrowth ticker fires every in-game hour; rejection-
samples grass tiles and plants a sapling with ~30% probability (capped
at MAP_TREE_LIMIT=60). New `paint_plant_tree` designation lets the
player manually plant — ghost sapling registered as a build_site that
ConstructionProvider fulfils. Stage round-trips through save/load.
Initial seed mixes 4 saplings + 6 mature so growth is visible day 1.

Quarry: new BigRockNode entity (2×2 permanent stone outcrop, never
depletes). 3 nodes seeded far from cabin. New QuarryWorkbench
(extends Workbench, auto-FOREVER `quarry_stone` bill, recipe drops
1 stone per 300 work-ticks). New `paint_quarry` designation only
accepts BigRockNode tiles. CraftingProvider now supports recipes
with `ingredient_count == 0` — skips ingredient-fetch and goes
straight to walk+craft toils. Recipe gains `ingredient_count` field
(defaults 0). Save/load layering: big_rock_node spawns at priority 0
(same as rock/tree), quarry_workbench at priority 2 (after the node).

UI: Plant tree + Build quarry buttons added to Build drawer.
build_drawer_thumb gains `plant_tree` (sapling sprout in dirt) and
`paint_quarry` (stone block + chisel + cut-stone pile) shapes.
inspect_tooltip recognises BigRockNode + shows tree growth stage on
hover.

Delegation: gdscript-refactor (Sonnet ×2) for trees full impl +
quarry skeleton; quick-edit (Haiku) for CraftingProvider no-ingredient
plumbing + TopBar polish; integration handled on Opus.
2026-05-16 16:36:16 +01:00

451 lines
16 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:
# 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)