From dbcc6f6e5963a03fd94a91440bf091b83c00d7ea Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Thu, 5 Nov 2026 15:01:59 +0000 Subject: [PATCH] Tutorial: Old Tam's hut outline beside the fire, pieces snap into it --- data/tutorial.json | 8 +++- docs/TUTORIAL_DESIGN.md | 8 +++- scripts/building/build_system.gd | 19 ++++++-- scripts/core/guide.gd | 38 ++++++++++++++++ scripts/ui/hut_outline.gd | 74 ++++++++++++++++++++++++++++++++ tests/test_tutorial_full.gd | 18 ++++++-- 6 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 scripts/ui/hut_outline.gd diff --git a/data/tutorial.json b/data/tutorial.json index afc24ce..36d30d4 100644 --- a/data/tutorial.json +++ b/data/tutorial.json @@ -285,9 +285,15 @@ "id": "hut", "chapter": "night", "title": "Build a little hut", - "text": "Nights get cold, and things prowl. At the bench: floors, walls, a door and a roof. Walls stand on floors, roofs on walls. Done when you can stand inside out of the weather.", + "text": "Nights get cold, and things prowl. Build a hut: I've marked out a good spot by your fire, pale as a ghost. Floors, walls and a door, then the roof, all from the bench. Put a piece near its place in the outline and it'll go right in. Done when you can stand inside out of the weather.", "goal": { "shelter": true + }, + "marker": { + "nearest": "outline" + }, + "setup": { + "outline": "hut" } }, { diff --git a/docs/TUTORIAL_DESIGN.md b/docs/TUTORIAL_DESIGN.md index 877fa6a..88304e8 100644 --- a/docs/TUTORIAL_DESIGN.md +++ b/docs/TUTORIAL_DESIGN.md @@ -27,7 +27,13 @@ real worlds) are proposals. - tests/test_tutorial_full.gd: a bot plays all 22 steps through Actions, in order, to the card. (Bulk materials, like a hut's wood, go straight into its bag; every goal is done as a player does it.) -- Not yet: a ghost outline for the hut (§2 step 17), sounds for the tick. +- The hut outline (§2 step 17): Old Tam marks out a 20-piece hut, pale + as a ghost, on the first clear flat spot 5 m or more from your fire + (scripts/ui/hut_outline.gd). A piece held within 2 m of its matching + ghost snaps right into it; each ghost fades as it's filled, and the + marker points at the outline. Building elsewhere still counts. The + outline goes when the step ends or the tutorial is left. +- Not yet: sounds for the tick. *As built in T1:* - Actions has signals for what players do: `crafted`, `harvested` (with diff --git a/scripts/building/build_system.gd b/scripts/building/build_system.gd index 4ed6363..a31549b 100644 --- a/scripts/building/build_system.gd +++ b/scripts/building/build_system.gd @@ -36,6 +36,7 @@ var pieces_root: Node3D var _hash: Dictionary = {} ## Vector3i -> Array of [piece, point] var last_blocker: Object ## what the last failed overlap check hit (debugging) +var outline: Node3D = null ## the tutorial's hut outline, while it's up (placement snaps to it) var _dirty := true func setup(w: Node3D) -> void: @@ -206,6 +207,15 @@ func placement(def: Dictionary, hit: Dictionary, yaw: float) -> Dictionary: var info: Dictionary = BuildPiece.kinds()[kind] var hp: Vector3 = hit.position var cands := [] + # The tutorial's hut outline (ui/hut_outline.gd): a piece aimed near an + # empty slot of its own kind goes there, like building to a plan. + if outline != null and is_instance_valid(outline): + for s in outline.slots: + if s[0] == String(def.id) and (s[2] as Node3D).visible: + var sxf: Transform3D = s[1] + var d := sxf.origin.distance_to(hp) + if d < 2.0: + cands.append([d * 0.3, sxf]) for piece in build_pieces(): if piece.global_position.distance_to(hp) > 4.5: continue @@ -394,16 +404,19 @@ func _hut_place(out: Dictionary, center: Vector3, yaw: float, id: String, local: ## A spot near `near` with a clear, fairly flat `half`-metre square: nothing ## solid in the way and the ground within 0.6 m across it. Searches outward ## in rings. Returns `near` itself if nothing is found. -func debug_find_site(near: Vector3, half: float) -> Vector3: +func debug_find_site(near: Vector3, half: float, step: float = 4.0, min_dist: float = 0.0) -> Vector3: var space := world.get_world_3d().direct_space_state var box := BoxShape3D.new() box.size = Vector3(half * 2.0 + 2.0, 6.0, half * 2.0 + 2.0) var ex: Array[RID] = [terrain.get_node("TerrainBody").get_rid(), world.player.get_rid()] - for ring in range(0, 12): + var rings := int(ceil(48.0 / step)) + for ring in range(0, rings): + if ring * step < min_dist: + continue var n := maxi(1, ring * 6) for i in range(n): var a := TAU * i / n - var c := near + Vector3(cos(a), 0, sin(a)) * ring * 4.0 + var c := near + Vector3(cos(a), 0, sin(a)) * ring * step if not terrain.inside_boundary(c.x, c.z, half + 4.0): continue var hs: Array[float] = [] diff --git a/scripts/core/guide.gd b/scripts/core/guide.gd index acf45c7..309bc47 100644 --- a/scripts/core/guide.gd +++ b/scripts/core/guide.gd @@ -120,6 +120,7 @@ func start(w: Node3D, from_saved: Dictionary = {}) -> void: _enter() func stop() -> void: + _clear_outline() active = false world = null @@ -145,7 +146,16 @@ func _enter() -> void: _check() # (done early? it ticks straight away) const DemoWolf = preload("res://scripts/creatures/tutorial_wolf.gd") +const HutOutline = preload("res://scripts/ui/hut_outline.gd") var demo_wolf: Node3D = null +var outline: Node3D = null ## the hut outline, while the hut step is on + +func _clear_outline() -> void: + if outline != null and is_instance_valid(outline): + outline.queue_free() + outline = null + if world != null and is_instance_valid(world): + world.build.outline = null ## A step's set-up: the clock, some animals, the demo wolf. func _setup(su: Dictionary) -> void: @@ -168,6 +178,28 @@ func _setup(su: Dictionary) -> void: if world.terrain.inside_boundary(at.x, at.z, 4.0) and world.terrain.sample_height(at.x, at.z) > world.terrain.water_level + 0.3: world.creatures.spawn(String(sp.kind), at) break + if String(su.get("outline", "")) == "hut": + _clear_outline() + # A good spot a few metres from your fire (or from you). + # Search out from the fire in small rings, starting far enough out + # that the hut (3 m half-width) leaves the fire 2 m clear of its walls. + var near: Vector3 = world.player.global_position + var step := 4.0 + var min_dist := 0.0 + for pc in world.all_pieces(): + if pc.item_id == "campfire" and pc.global_position.distance_to(near) < 30.0: + near = pc.global_position + step = 1.5 + min_dist = 5.0 + var site: Vector3 = world.build.debug_find_site(near, 3.0, step, min_dist) + var yaw: float = world.player.rotation.y + var b := Basis(Vector3.UP, yaw) + var first: Transform3D = world.build._free_xf("floor", site + b * Vector3(-1, 0, -1), yaw, true) + outline = HutOutline.new() + outline.name = "HutOutline" + world.add_child(outline) + outline.setup(world, first) + world.build.outline = outline if su.get("demo_wolf", false): if demo_wolf != null and is_instance_valid(demo_wolf): demo_wolf.queue_free() @@ -200,6 +232,7 @@ func _check() -> void: _advance() func _advance() -> void: + _clear_outline() # (it was only for the step that's ending) var s := current() done.append(String(s.id)) step_done.emit(index, s) @@ -349,6 +382,11 @@ func find_marker() -> Vector3: for pc in world.all_pieces(): if pc.item_id == what.substr(6): cands.append(pc) + elif what == "outline": + # Wherever the outline still has pieces to build: its middle. + if outline != null and is_instance_valid(outline): + return outline.center() + return Vector3.INF elif what == "lake": # The lake's shore, on your side of it. var c: Vector2 = world.terrain.lake_center diff --git a/scripts/ui/hut_outline.gd b/scripts/ui/hut_outline.gd new file mode 100644 index 0000000..a448503 --- /dev/null +++ b/scripts/ui/hut_outline.gd @@ -0,0 +1,74 @@ +extends Node3D +## The tutorial's hut outline (docs/TUTORIAL_DESIGN.md §2, step 17): a pale, +## see-through 4 x 4 m hut on a good spot near your fire, the same 20 pieces +## the game's own snapping makes (floors, walls and a door, roofs, gables). +## Each piece of the outline fades out once a real piece of that kind stands +## in its place. It's a suggestion: you can build anywhere. + +## [item, offset from the first floor, yaw] (from build_system.gd +## debug_build_hut at yaw 0: the pieces snap to a fixed grid). +const LAYOUT := [ + ["wood_floor", Vector3(0, 0, 0), 0.0], ["wood_floor", Vector3(2, 0, 0), 0.0], + ["wood_floor", Vector3(0, 0, 2), 0.0], ["wood_floor", Vector3(2, 0, 2), 0.0], + ["wood_wall", Vector3(0, 0, -1), 0.0], ["wood_wall", Vector3(0, 0, 3), 0.0], + ["wood_wall", Vector3(2, 0, -1), 0.0], ["wood_door", Vector3(2, 0, 3), 0.0], + ["wood_wall", Vector3(3, 0, 0), PI * 0.5], ["wood_wall", Vector3(-1, 0, 0), PI * 0.5], + ["wood_wall", Vector3(3, 0, 2), PI * 0.5], ["wood_wall", Vector3(-1, 0, 2), PI * 0.5], + ["wood_roof", Vector3(0, 2, 3), 0.0], ["wood_roof", Vector3(0, 2, -1), PI], + ["wood_roof", Vector3(2, 2, 3), 0.0], ["wood_roof", Vector3(2, 2, -1), PI], + ["wood_gable", Vector3(-1, 2, 2), PI * 0.5], ["wood_gable", Vector3(-1, 2, 0), -PI * 0.5], + ["wood_gable", Vector3(3, 2, 2), PI * 0.5], ["wood_gable", Vector3(3, 2, 0), -PI * 0.5], +] +const MATCH := 0.35 ## a real piece this close to a slot fills it + +var world: Node3D +var origin := Transform3D() ## the first floor's place +var slots: Array = [] ## [item, Transform3D, MeshInstance3D] +var _check := 0.0 + +## Lays the outline out with its first floor at `xf` (from build_system's +## free placement, so it sits level on the ground). +func setup(w: Node3D, xf: Transform3D) -> void: + world = w + origin = xf + var m := StandardMaterial3D.new() + m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + m.albedo_color = Color(0.6, 0.88, 1.0, 0.22) + m.cull_mode = BaseMaterial3D.CULL_DISABLED + m.no_depth_test = false + for e in LAYOUT: + var t := Transform3D(xf.basis * Basis(Vector3.UP, float(e[2])), xf * (e[1] as Vector3)) + var mi := MeshInstance3D.new() + mi.mesh = ItemDB.mesh_for(String(e[0])) + mi.material_override = m + mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(mi) + mi.global_transform = t + slots.append([String(e[0]), t, mi]) + +## The middle of the hut (the guide's marker points here). +func center() -> Vector3: + return origin * Vector3(1, 0, 1) + +func _process(delta: float) -> void: + _check -= delta + if _check <= 0.0: + _check = 0.5 + refresh() + +## Hides each slot a real piece of its kind now fills. Returns how many are +## left to build. +func refresh() -> int: + var left := 0 + var built: Array = world.build.build_pieces() if world != null else [] + for s in slots: + var filled := false + for p in built: + if p.item_id == s[0] and (p as Node3D).global_position.distance_to((s[1] as Transform3D).origin) < MATCH: + filled = true + break + (s[2] as MeshInstance3D).visible = not filled + if not filled: + left += 1 + return left diff --git a/tests/test_tutorial_full.gd b/tests/test_tutorial_full.gd index 69c74d9..3454403 100644 --- a/tests/test_tutorial_full.gd +++ b/tests/test_tutorial_full.gd @@ -283,14 +283,26 @@ func _fishing() -> void: func _night() -> void: print("5. shelter and night") check(_at("hut"), "now: build a hut") - var site: Vector3 = world.build.debug_find_site(fire.global_position + Vector3(6, 0, 6), 3.0) - var yaw: float = player.rotation.y - var hut: Dictionary = await world.build.debug_build_hut(site, yaw) + print(" the outline") + var ol: Node3D = G.outline + check(ol != null and ol.slots.size() == 20 and ol.refresh() == 20, "Old Tam marks out a hut: a 20-piece outline") + check(ol.center().distance_to(fire.global_position) < 9.0 and ol.center().distance_to(fire.global_position) > 3.0, + "near the fire, on clear ground (%.0f m away), not on it" % ol.center().distance_to(fire.global_position)) + G._marker = G.find_marker() + check(G.marker_position().distance_to(ol.center()) < 0.05, "the marker points at it") + var slot0: Transform3D = ol.slots[0][1] + var aim := {"position": slot0.origin + Vector3(0.9, 0, -0.7), "normal": Vector3.UP, "collider": world.terrain.get_node("TerrainBody")} + var r: Dictionary = world.build.placement(DB.get_item("wood_floor"), aim, player.rotation.y + 0.4) + check(r.ok and r.xf.origin.distance_to(slot0.origin) < 0.01, "a floor aimed near its place in the outline goes right in") + var yaw: float = ol.origin.basis.get_euler().y + var hut: Dictionary = await world.build.debug_build_hut(ol.center(), yaw) for _i in range(3): await physics_frame check(hut.placed == 20, "a hut (the same snapping a player uses: %d pieces)" % hut.placed) + check(not is_instance_valid(ol) or ol.is_queued_for_deletion() or ol.refresh() == 0, "every piece of the outline filled") var b := Basis(Vector3.UP, yaw) var center: Vector3 = hut.pieces[0].global_transform * Vector3(1, 0, 1) + check(center.distance_to(ol.center() if is_instance_valid(ol) else center) < 0.05, "(built right on it)") player.global_position = center + b * Vector3(0.6, 0.05, -0.6) sv._shelter_left = 0.0 sv.step(0.2)