Tutorial: Old Tam's hut outline beside the fire, pieces snap into it

This commit is contained in:
Paul Bailey
2026-11-05 15:01:59 +00:00
parent f93ebe1bea
commit dbcc6f6e59
6 changed files with 157 additions and 8 deletions

View File

@@ -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"
}
},
{

View File

@@ -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

View File

@@ -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] = []

View File

@@ -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

74
scripts/ui/hut_outline.gd Normal file
View File

@@ -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

View File

@@ -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)