T1 tutorial: Actions events, the Guide autoload, chapter 1 with Old Tam the hermit, guide panel and marker, saved progress; roadmap: auto-update plan

This commit is contained in:
Paul Bailey
2026-11-05 14:14:32 +00:00
parent e03411ffdb
commit 5b5dd3d609
11 changed files with 834 additions and 5 deletions

View File

@@ -10,6 +10,17 @@ extends Node
signal notify(text: String) ## short HUD toast ("+4 Wood", "Need an axe")
signal item_gained(id: String, n: int)
## What players did (docs/TUTORIAL_DESIGN.md §3): the tutorial's Guide (and
## later hints and addons) listen to these. Emitted where the game decides
## (single-player, or on a server for each player's character).
signal crafted(player: Node, recipe_id: String, output: String, count: int)
signal harvested(player: Node, node: Node, felled: bool)
signal placed(player: Node, item_id: String, piece: Node)
signal ate(player: Node, item_id: String)
signal killed(player: Node, kind: String)
signal butchered(player: Node, kind: String)
signal caught(player: Node, fish: String)
signal slept(player: Node)
const STATION_RANGE := 6.0
const REACH := 3.2
@@ -207,6 +218,7 @@ func craft(player: Node, recipe_id: String) -> bool:
for id in r.inputs:
inv.remove(id, int(r.inputs[id]))
_give(player, r.output, r.count)
crafted.emit(player, recipe_id, String(r.output), int(r.count))
return true
func pickup(player: Node3D, pickup_node: Node3D) -> bool:
@@ -250,6 +262,7 @@ func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool:
for id in drops:
_give(player, id, int(drops[id]))
use_held(player)
harvested.emit(player, node, bool(node.depleted))
return true
func can_place(player: Node3D, slot: int) -> bool:
@@ -271,7 +284,8 @@ func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool:
notify.emit("Can't place that here")
return false
player.inventory.take_from(slot, 1)
world.spawn_piece(def, xf)
var piece: Node3D = world.spawn_piece(def, xf)
placed.emit(player, id, piece)
return true
## Takes a placed piece back into your inventory. Building pieces that lose
@@ -369,6 +383,7 @@ func use_bed(player: Node3D, bed: Node3D) -> bool:
notify.emit(why)
return false
world.sleep(player)
slept.emit(player)
return true
# --- hunting and combat ---------------------------------------------------------
@@ -420,6 +435,7 @@ func melee(player: Node3D, target: Node3D) -> bool:
use_held(player)
if target.is_dead():
notify.emit("Killed a %s" % target.display_name().to_lower())
killed.emit(player, String(target.kind))
return true
## A melee blow at another player (online, with PvP on). Online only.
@@ -465,6 +481,7 @@ func projectile_hit(shooter: Node3D, target: Node3D, damage: float, at: Vector3,
target.take_hit(damage, shooter.global_position if shooter != null else at, embed_id)
if was_alive and target.is_dead():
tell(shooter, "Killed a %s" % target.display_name().to_lower())
killed.emit(shooter, String(target.kind))
## Throws the spear in `slot` from `origin` along `dir`.
func throw_spear(player: Node3D, slot: int, origin: Vector3, dir: Vector3, world: Node3D) -> bool:
@@ -540,6 +557,7 @@ func butcher(player: Node3D, carcass: Node3D) -> bool:
return false
var drops: Dictionary = carcass.butcher_drops()
carcass.remove_from_group("carcass")
butchered.emit(player, String(carcass.kind))
for id in drops:
var got := _give(player, id, int(drops[id]))
if got < int(drops[id]):
@@ -584,6 +602,7 @@ func reel(player: Node3D, world: Node3D) -> bool:
if _give(player, caught, 1) == 0:
world.drop_item(caught, 1, Transform3D(Basis(), player.global_position + Vector3(0, 0.1, 0)))
notify.emit("Caught a %s" % ItemDB.item_name(caught).to_lower())
emit_signal("caught", player, caught) # (the local `caught` hides the signal's name)
return true
## Puts on the cloak in `slot` (swapping out any cloak you were wearing).
@@ -642,8 +661,10 @@ func eat(player: Node3D, slot: int) -> bool:
notify.emit("You're full")
return false
player.inventory.take_from(slot, 1)
var eaten := String(s.id)
player.survival.eat(food)
notify.emit("Ate %s" % ItemDB.item_name(s.id).to_lower())
notify.emit("Ate %s" % ItemDB.item_name(eaten).to_lower())
ate.emit(player, eaten)
# Raw meat and fish can make you ill (P4). Cooked, smoked or salted can't.
if food_rng.randf() < float(food.get("risk", 0.0)):
player.survival.poison()

283
scripts/core/guide.gd Normal file
View File

@@ -0,0 +1,283 @@
extends Node
## Autoload "Guide": the tutorial (docs/TUTORIAL_DESIGN.md §3).
##
## Runs the steps in data/tutorial.json in order. Each step has a goal made
## of real game events (Actions' signals: items gained, things crafted,
## trees felled...) plus walking; the Guide counts them from the moment the
## tutorial starts, so a step you've already done ticks as soon as it comes
## up. Nothing is forced and nothing is on a timer.
##
## Goals:
## {"walk": metres} walked that far in all
## {"gained": id, "count": n} picked up / given that many
## {"crafted": recipe_id} (and "count")
## {"felled": "tree" | "rock"} chopped down / broken
## {"placed": item_id}, {"ate": item_id or "any"}, {"killed": kind},
## {"butchered": kind}, {"caught": "any"}, {"slept": true}
## {"continue": true} the player pressed Continue
## {"all": [goal, goal, ...]} every one of them
## Markers: {"nearest": "pickup:<id>" | "tree" | "rock"}, optionally
## {"unless_have": {id: n}} to hide it once you've got enough.
##
## Single-player only (the tutorial is your own world). The panel and the
## in-world marker are ui/guide.gd. Progress is saved with the world.
signal started
signal step_started(index: int, step: Dictionary)
signal step_done(index: int, step: Dictionary)
signal progressed ## a count went up (the panel redraws)
signal finished
const DATA := "res://data/tutorial.json"
var active := false
var steps: Array = []
var chapters: Array = []
var index := 0
var done: Array = [] ## step ids, in the order they ticked
var counters: Dictionary = {} ## "gained:stick" -> 3, "walk" -> 12.5, ...
var world: Node3D
var _last_pos := Vector3.INF
var _marker_t := 0.0
var _marker := Vector3.INF
func _ready() -> void:
_load_data()
var A: Node = get_node("/root/Actions")
A.item_gained.connect(_on_gained)
A.crafted.connect(_on_crafted)
A.harvested.connect(_on_harvested)
A.placed.connect(_on_placed)
A.ate.connect(_on_ate)
A.killed.connect(_on_killed)
A.butchered.connect(_on_butchered)
A.caught.connect(_on_caught)
A.slept.connect(_on_slept)
func _on_gained(id: String, n: int) -> void:
_count("gained:" + id, n)
func _on_crafted(_p: Node, rid: String, _out: String, _n: int) -> void:
_count("crafted:" + rid, 1)
func _on_harvested(_p: Node, node: Node, felled: bool) -> void:
if felled:
_count("felled:" + String(node.kind), 1)
func _on_placed(_p: Node, id: String, _piece: Node) -> void:
_count("placed:" + id, 1)
func _on_ate(_p: Node, id: String) -> void:
_count("ate:" + id, 1)
_count("ate:any", 1)
func _on_killed(_p: Node, kind: String) -> void:
_count("killed:" + kind, 1)
func _on_butchered(_p: Node, kind: String) -> void:
_count("butchered:" + kind, 1)
func _on_caught(_p: Node, fish: String) -> void:
_count("caught:" + fish, 1)
_count("caught:any", 1)
func _on_slept(_p: Node) -> void:
_count("slept:true", 1)
func _load_data() -> void:
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(DATA)) if FileAccess.file_exists(DATA) else null
if d is Dictionary:
steps = d.get("steps", [])
chapters = d.get("chapters", [])
guide_name = String(d.get("guide", {}).get("name", ""))
## Who's talking you through it (data/tutorial.json "guide": Old Tam, the
## island's hermit).
var guide_name := ""
# --- running it -----------------------------------------------------------------------
func start(w: Node3D, from_saved: Dictionary = {}) -> void:
world = w
active = true
index = 0
done = []
counters = {}
_last_pos = Vector3.INF
if not from_saved.is_empty():
load_data(from_saved)
started.emit()
_enter()
func stop() -> void:
active = false
world = null
func current() -> Dictionary:
return steps[index] if active and index < steps.size() else {}
func is_finished() -> bool:
return active and index >= steps.size()
func chapter_title(step: Dictionary) -> String:
for c in chapters:
if c.id == step.get("chapter", ""):
return String(c.title)
return ""
func _enter() -> void:
if index >= steps.size():
finished.emit()
return
step_started.emit(index, steps[index])
_marker_t = 0.0
_check() # (done early? it ticks straight away)
## The player read a "Continue" step.
func continue_step() -> void:
_count("continue:true", 1)
## Skips the current step (pause menu, T2).
func skip() -> void:
if not current().is_empty():
_advance()
func _count(key: String, n: float) -> void:
counters[key] = float(counters.get(key, 0.0)) + n
if active:
progressed.emit()
_check()
func _check() -> void:
var s := current()
if s.is_empty():
return
if _met(s.goal):
_advance()
func _advance() -> void:
var s := current()
done.append(String(s.id))
step_done.emit(index, s)
index += 1
# A "continue" only counts for the step it was pressed on.
counters.erase("continue:true")
_enter()
# --- goals -------------------------------------------------------------------------------
## [[label, have, need], ...] for each part of `goal`.
func parts(goal: Dictionary) -> Array:
if goal.has("all"):
var out := []
for g in goal.all:
out.append_array(parts(g))
return out
var need := float(goal.get("count", 1))
for k in ["gained", "crafted", "felled", "placed", "ate", "killed", "butchered", "caught", "slept", "continue"]:
if goal.has(k):
var key := "%s:%s" % [k, String(goal[k]).to_lower() if goal[k] is bool else String(goal[k])]
return [[_label(k, goal), float(counters.get(key, 0.0)), need]]
if goal.has("walk"):
return [["Walk", floorf(float(counters.get("walk", 0.0))), float(goal.walk)]]
return []
func _label(kind: String, goal: Dictionary) -> String:
if goal.has("label"):
return String(goal.label)
match kind:
"gained": return _plural(ItemDB.item_name(String(goal.gained)), int(goal.get("count", 1)))
"crafted":
var r: Dictionary = ItemDB.recipes.get(String(goal.crafted), {})
return ItemDB.item_name(String(r.get("output", goal.crafted)))
"felled": return "Tree" if goal.felled == "tree" else "Boulder"
"continue": return ""
return kind.capitalize()
static func _plural(name: String, n: int) -> String:
if n <= 1 or name.ends_with("s") or name.to_lower() in ["fiber", "fibre", "wood", "stone", "flint", "clay", "salt"]:
return name
return name + "s"
func _met(goal: Dictionary) -> bool:
for p in parts(goal):
if float(p[1]) < float(p[2]):
return false
return true
## "Sticks 2/3 · Stone 1/3" for the current step (empty for one-off goals).
func progress_text() -> String:
var s := current()
if s.is_empty():
return ""
var bits := []
for p in parts(s.goal):
if float(p[2]) > 1.0:
bits.append("%s %d/%d" % [p[0], mini(int(p[1]), int(p[2])), int(p[2])])
elif not String(p[0]).is_empty():
bits.append(("✓ " if float(p[1]) >= 1.0 else "") + String(p[0]))
return " · ".join(PackedStringArray(bits))
# --- walking and the marker ------------------------------------------------------------
func _process(delta: float) -> void:
if not active or world == null or not is_instance_valid(world) or world.player == null:
return
var p: Vector3 = world.player.global_position
if _last_pos != Vector3.INF:
var d := Vector2(p.x - _last_pos.x, p.z - _last_pos.z).length()
if d > 0.01 and d < 5.0: # (not a teleport or a respawn)
_count("walk", d)
_last_pos = p
_marker_t -= delta
if _marker_t <= 0.0:
_marker_t = 0.5
_marker = find_marker()
## Called by tests (no frames): moves the walk counter on by `metres`.
func walked(metres: float) -> void:
_count("walk", metres)
func marker_position() -> Vector3:
return _marker
## Where the current step's marker goes: the nearest thing it asks for, or
## INF (no marker, or you've got what it was for).
func find_marker() -> Vector3:
var s := current()
if s.is_empty() or not s.has("marker") or world == null:
return Vector3.INF
var m: Dictionary = s.marker
for id in m.get("unless_have", {}):
if world.player.inventory.count(id) >= int(m.unless_have[id]):
return Vector3.INF
var what := String(m.get("nearest", ""))
var from: Vector3 = world.player.global_position
var best := Vector3.INF
var best_d := INF
var cands: Array = []
if what.begins_with("pickup:"):
var id := what.substr(7)
for pk in world.props.pickups.values():
if is_instance_valid(pk) and not pk.taken and pk.item_id == id:
cands.append(pk)
elif what in ["tree", "rock"]:
for n in world.props.resource_nodes.values():
if is_instance_valid(n) and n.kind == what and not n.depleted:
cands.append(n)
for c in cands:
var d: float = (c as Node3D).global_position.distance_to(from)
if d < best_d:
best_d = d
best = (c as Node3D).global_position
return best
# --- saving --------------------------------------------------------------------------------
func save_data() -> Dictionary:
return {"index": index, "done": done.duplicate(), "counters": counters.duplicate()} if active else {}
func load_data(d: Dictionary) -> void:
index = clampi(int(d.get("index", 0)), 0, steps.size())
done = (d.get("done", []) as Array).duplicate()
counters = (d.get("counters", {}) as Dictionary).duplicate()

177
scripts/ui/guide.gd Normal file
View File

@@ -0,0 +1,177 @@
extends Control
## The tutorial's guide panel and marker (docs/TUTORIAL_DESIGN.md §1): what to
## do now, top right under the clock, with a progress count; a tick when a
## step's done; a pale glowing post where to go, and an arrow with the
## distance when it's off-screen. H hides the panel. Drawn from the Guide
## autoload; hidden when no tutorial is running.
const UITheme = preload("res://scripts/ui/theme.gd")
var player: Node3D
var _box: PanelContainer
var _chapter: Label
var _title: Label
var _text: Label
var _progress: Label
var _tick: Label
var _arrow: Label
var _marker: Node3D
var _hidden := false
func setup(p: Node3D) -> void:
player = p
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
_box = UITheme.panel()
_box.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
_box.grow_horizontal = Control.GROW_DIRECTION_BEGIN
_box.offset_right = -22
_box.offset_top = 48
_box.custom_minimum_size = Vector2(340, 0)
_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_box)
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 4)
v.custom_minimum_size = Vector2(320, 0)
_box.add_child(v)
_chapter = UITheme.label("", 13, UITheme.TEXT_DIM)
v.add_child(_chapter)
_title = UITheme.label("", 19)
v.add_child(_title)
_text = UITheme.label("", 15, UITheme.TEXT_DIM)
_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_text.custom_minimum_size = Vector2(320, 0)
v.add_child(_text)
_progress = UITheme.label("", 15, Color(0.95, 0.82, 0.5))
v.add_child(_progress)
_tick = UITheme.label("", 22, Color(0.55, 0.9, 0.45))
_tick.set_anchors_and_offsets_preset(Control.PRESET_CENTER_TOP)
_tick.offset_top = 90
_tick.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_tick.modulate.a = 0.0
add_child(_tick)
_arrow = UITheme.label("", 20, Color(0.75, 0.95, 1.0))
_arrow.visible = false
add_child(_arrow)
Guide.step_started.connect(func(_i, _s): _redraw())
Guide.progressed.connect(_redraw)
Guide.step_done.connect(_on_done)
Guide.started.connect(_redraw)
Guide.finished.connect(_on_finished)
_redraw()
func _unhandled_input(event: InputEvent) -> void:
if Guide.active and event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_H:
_hidden = not _hidden
_redraw()
func _redraw() -> void:
var s: Dictionary = Guide.current()
visible = Guide.active
_box.visible = Guide.active and not _hidden and not s.is_empty()
if s.is_empty():
return
_chapter.text = "%s · step %d of %d (H hides)" % [Guide.chapter_title(s), Guide.index + 1, Guide.steps.size()]
_title.text = String(s.title)
# The hermit's words, in quotes, with his name.
_text.text = ("%s: \"%s\"" % [Guide.guide_name, String(s.text)]) if not Guide.guide_name.is_empty() else String(s.text)
_progress.text = Guide.progress_text()
func _on_done(_i: int, s: Dictionary) -> void:
_tick.text = "✓ " + String(s.title)
var tw := create_tween()
_tick.modulate.a = 1.0
tw.tween_interval(1.4)
tw.tween_property(_tick, "modulate:a", 0.0, 0.6)
func _on_finished() -> void:
_box.visible = false
_tick.text = "✓ Chapter done. More of the tutorial is on its way."
_tick.modulate.a = 1.0
var tw := create_tween()
tw.tween_interval(3.0)
tw.tween_property(_tick, "modulate:a", 0.0, 1.0)
func _process(_delta: float) -> void:
_update_marker()
## The glowing post at the target, and the arrow at the screen edge.
func _update_marker() -> void:
var at: Vector3 = Guide.marker_position() if Guide.active else Vector3.INF
if _marker != null and (not is_instance_valid(_marker) or _marker.get_parent() != Guide.world):
if is_instance_valid(_marker):
_marker.queue_free()
_marker = null # (its world went: a reload, or back to the title)
if _marker == null and Guide.active and Guide.world != null:
_marker = make_marker()
Guide.world.add_child(_marker)
if _marker == null:
_arrow.visible = false
return
_marker.visible = at != Vector3.INF
if at == Vector3.INF:
_arrow.visible = false
return
_marker.global_position = at
var cam := get_viewport().get_camera_3d()
if cam == null or _hidden:
_arrow.visible = false
return
var vis := not cam.is_position_behind(at)
var sp := cam.unproject_position(at + Vector3(0, 1.0, 0))
var r := get_viewport_rect()
if vis and r.grow(-40).has_point(sp):
_arrow.visible = false
return
# Off-screen: an arrow at the edge, pointing the way, with the distance.
var c := r.size * 0.5
var dir := (sp - c) * (-1.0 if not vis else 1.0)
if dir.length() < 1.0:
dir = Vector2(0, -1)
dir = dir.normalized()
var edge := c + dir * (minf(c.x, c.y) - 70.0)
edge = edge.clamp(Vector2(40, 40), r.size - Vector2(140, 60))
var arrows := ["→", "↘", "↓", "↙", "←", "↖", "↑", "↗"]
var k := wrapi(roundi(atan2(dir.y, dir.x) / (PI / 4.0)), 0, 8)
var who: Node3D = Guide.world.player if Guide.world != null else player
_arrow.text = "%s %d m" % [arrows[k], roundi(who.global_position.distance_to(at))]
_arrow.position = edge
_arrow.visible = true
## A pale, glowing post with a ring at its foot.
static func make_marker() -> Node3D:
var root := Node3D.new()
root.name = "GuideMarker"
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
m.albedo_color = Color(0.55, 0.85, 1.0, 0.35)
m.cull_mode = BaseMaterial3D.CULL_DISABLED
var beam := MeshInstance3D.new()
var cyl := CylinderMesh.new()
cyl.top_radius = 0.06
cyl.bottom_radius = 0.14
cyl.height = 5.0
cyl.material = m
beam.mesh = cyl
beam.position = Vector3(0, 2.6, 0)
beam.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
root.add_child(beam)
var ring := MeshInstance3D.new()
var torus := TorusMesh.new()
torus.inner_radius = 0.55
torus.outer_radius = 0.7
torus.material = m
ring.mesh = torus
ring.position = Vector3(0, 0.05, 0)
ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
root.add_child(ring)
if DisplayServer.get_name() != "headless":
var l := OmniLight3D.new()
l.light_color = Color(0.55, 0.85, 1.0)
l.light_energy = 0.6
l.omni_range = 4.0
l.position = Vector3(0, 1.0, 0)
root.add_child(l)
return root

View File

@@ -174,6 +174,11 @@ func _build_stats(root: Control) -> void:
_clock.offset_right = -22
_clock.offset_top = 14
root.add_child(_clock)
# The tutorial's guide panel, under the clock (hidden unless one's running).
var guide := preload("res://scripts/ui/guide.gd").new()
guide.name = "Guide"
root.add_child(guide)
guide.setup(player)
func _process(_d: float) -> void:
_fade_chat()

View File

@@ -69,7 +69,8 @@ var thieves: Node3D ## thieves.gd: crows and foxes after food left o
var hazards: Node3D ## hazards.gd: lightning on trees, hard-winter ice (P4)
var stones: Node3D ## standing_stones.gd: the altar and the Old Stag (P5)
var barrow: Node3D ## barrow.gd: the burial mound and its guards (scenery; guards where creatures run)
var _saved_bear: Dictionary = {} ## its saved state, kept until bears exists (and re-saved if it never does)
var _saved_bear: Dictionary = {}
var _saved_tutorial: Dictionary = {} ## the tutorial's progress (Guide), from the save ## its saved state, kept until bears exists (and re-saved if it never does)
var net_sync: Node ## net_sync.gd when playing on a server, else null
var has_bed_spawn := false
var bed_spawn := Vector3.ZERO ## position of the bed you respawn at
@@ -417,6 +418,10 @@ func _on_terrain_ready() -> void:
wolves.setup(self, den)
if not "--no-creatures" in OS.get_cmdline_user_args():
_setup_bear()
# The tutorial (docs/TUTORIAL_DESIGN.md): a world made for it (meta.json
# "tutorial"), or any world with `-- --tutorial` (for now, T1).
if bool(Saves.read_meta(Saves.current_id).get("tutorial", false)) or "--tutorial" in OS.get_cmdline_user_args():
Guide.start(self, _saved_tutorial)
_maybe_capture()
## The island's bear and its patch (bear_range.gd), and the crows and foxes
@@ -542,6 +547,8 @@ func save_now() -> bool:
func _exit_tree() -> void:
if server_mode and Saves.has_world() and _session_time > 0.0:
save_now()
if Guide.world == self:
Guide.stop()
func _notification(what: int) -> void:
if what != NOTIFICATION_WM_CLOSE_REQUEST or menu_mode:
@@ -601,6 +608,7 @@ func collect_save() -> Dictionary:
},
"dropped": dropped,
"time": {"day": day_night.day, "time": day_night.time},
"tutorial": Guide.save_data() if Guide.world == self else _saved_tutorial,
"spawn": {"bed": has_bed_spawn, "pos": [bed_spawn.x, bed_spawn.y, bed_spawn.z]},
"forage_regrow": regrow,
"regrow": props.regrow.duplicate(),
@@ -750,6 +758,7 @@ func _apply_save(s: Dictionary, with_player: bool) -> void:
props.pickups[uid].collected()
# Regrowth timers (older saves have none: everything gone starts a fresh timer).
props.restore_regrow(s.get("regrow", {}))
_saved_tutorial = s.get("tutorial", {}) if with_player else {}
if barrow != null:
barrow.load_data(s.get("barrow", {}))
if stones != null: