From 5b5dd3d609f47bc0a55fcbc791539e13426d31e2 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Thu, 5 Nov 2026 14:14:32 +0000 Subject: [PATCH] T1 tutorial: Actions events, the Guide autoload, chapter 1 with Old Tam the hermit, guide panel and marker, saved progress; roadmap: auto-update plan --- data/tutorial.json | 58 ++++++++ docs/ROADMAP.md | 30 +++++ docs/TUTORIAL_DESIGN.md | 24 +++- project.godot | 1 + scripts/core/actions.gd | 25 +++- scripts/core/guide.gd | 283 ++++++++++++++++++++++++++++++++++++++++ scripts/ui/guide.gd | 177 +++++++++++++++++++++++++ scripts/ui/hud.gd | 5 + scripts/world/world.gd | 11 +- tests/run_all.sh | 2 +- tests/test_tutorial.gd | 223 +++++++++++++++++++++++++++++++ 11 files changed, 834 insertions(+), 5 deletions(-) create mode 100644 data/tutorial.json create mode 100644 scripts/core/guide.gd create mode 100644 scripts/ui/guide.gd create mode 100644 tests/test_tutorial.gd diff --git a/data/tutorial.json b/data/tutorial.json new file mode 100644 index 0000000..c9172c6 --- /dev/null +++ b/data/tutorial.json @@ -0,0 +1,58 @@ +{ + "guide": { + "name": "Old Tam", + "about": "the hermit who's lived on this island longer than he'll admit" + }, + "chapters": [ + {"id": "tools", "title": "First tools"} + ], + "steps": [ + { + "id": "look", + "chapter": "tools", + "title": "Look around", + "text": "Awake at last! Washed up on my island, have you. Stretch your legs: WASD to walk, the mouse to look. Shift if you're in a hurry.", + "goal": {"walk": 10} + }, + { + "id": "sticks", + "chapter": "tools", + "title": "Gather sticks and stones", + "text": "Empty hands are no use here. Look at a stick or a stone on the ground and press E. Three of each, mind.", + "marker": {"nearest": "pickup:stick"}, + "goal": {"all": [{"gained": "stick", "count": 3}, {"gained": "stone", "count": 3}]} + }, + { + "id": "flax", + "chapter": "tools", + "title": "Pick wild flax", + "text": "See the pale, tall plants? Wild flax. Pick some. The fibre makes torches, bows and a decent bed.", + "marker": {"nearest": "pickup:fiber"}, + "goal": {"gained": "fiber", "count": 2} + }, + { + "id": "axe", + "chapter": "tools", + "title": "Craft a stone axe", + "text": "Now an axe. Two sticks and two flint: flint lies about, more by the water. Tab opens your pack, and the recipe's in there.", + "marker": {"nearest": "pickup:flint", "unless_have": {"flint": 2}}, + "goal": {"crafted": "stone_axe"} + }, + { + "id": "tree", + "chapter": "tools", + "title": "Chop down a tree", + "text": "Axe in hand (the number keys), hold the left button at a trunk and keep at it. A tree gives wood, and sticks when it falls.", + "marker": {"nearest": "tree"}, + "goal": {"felled": "tree"} + }, + { + "id": "pickaxe", + "chapter": "tools", + "title": "Craft a pickaxe and break a boulder", + "text": "Rocks are next. A pickaxe takes two sticks, three stone and a flint. Then go and break a boulder for stone and flint of your own.", + "marker": {"nearest": "rock"}, + "goal": {"all": [{"crafted": "stone_pickaxe"}, {"felled": "rock"}]} + } + ] +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 58fd039..3e869f6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -88,6 +88,36 @@ phases M2.0-M2.7). - Better UI, settings, controls - Audio, music, weather - Packaged builds for Win/Mac/Linux (+ Mac signing/notarisation) +- **Updates**: the game (and a dedicated server) needs to notice when a new + version is out, download what's needed and install it. See below. + +### Updates (to design with the packaged builds) +Needed because every network change bumps the protocol (12 as of P5), and +an out-of-date game can't join an up-to-date server: today that means +`git pull` and a restart on every machine. +- **Knowing there's an update:** the game checks a small version file + (version number, protocol, per-platform download URLs and checksums) at + start-up and from the title screen. The server checks too. When joining + a server with a newer protocol, the "different game version" refusal + should offer to update instead of just saying so. +- **Getting it:** download only what changed where possible (the game's + `.pck` data separately from the engine binary, which rarely changes), to + a staging folder, verified by checksum (and signature) before anything + is replaced. +- **Installing it:** swap in the new files on the next start (a tiny + launcher or a restart-and-replace step), keep the previous version to + roll back to if the new one fails to start. Mac: signed and notarised so + Gatekeeper accepts it; Windows: signed. Worlds, profiles and server + config live in user data, so they're never touched; saves stay + readable by newer versions (they already load older saves). +- **Servers:** a headless server can update itself at a quiet moment + (nobody on, or after warning players), or an admin runs an update + command; the launchd/systemd service files restart it. +- **Where updates come from:** GitHub Releases is the simple start (the + version file and builds attached to a release); itch.io's butler or + Steam would handle it for us if the game ships there. +- **Addons (M3)** will want the same: a version per addon and the server + telling players which addons (and versions) it needs. ## Honest overall estimate A genuinely playable **self-hosted survival game with a working sandboxed addon diff --git a/docs/TUTORIAL_DESIGN.md b/docs/TUTORIAL_DESIGN.md index dab6a27..d0b8166 100644 --- a/docs/TUTORIAL_DESIGN.md +++ b/docs/TUTORIAL_DESIGN.md @@ -1,6 +1,28 @@ # Tutorial world design -Status: **proposal**. Nothing here is built yet. +Status: **T1 built** (the Guide, Actions' events, chapter 1 in +data/tutorial.json, the guide panel and marker); T2-T4 are proposals. + +*As built in T1:* +- Actions has signals for what players do: `crafted`, `harvested` (with + whether it fell), `placed`, `ate`, `killed`, `butchered`, `caught`, + `slept` (plus the old `item_gained`). +- The `Guide` autoload (scripts/core/guide.gd) runs data/tutorial.json: + goals `walk`, `gained`, `crafted`, `felled`, `placed`, `ate`, `killed`, + `butchered`, `caught`, `slept`, `continue`, and `all` of several. It + counts from the tutorial's start, so a step done early ticks the moment + it comes up. Markers: the nearest pickup of a kind, tree or boulder, + hidden once you have what it's for (`unless_have`). +- The guide panel (scripts/ui/guide.gd) under the clock: chapter, step n of + m, title, how, and a count ("Sticks 2/3 · Stone 0/3"); a tick when a + step's done; H hides it. The marker is a pale glowing post and ring, with + an arrow and distance at the screen edge when it's off-screen. +- A world with `"tutorial": true` in its meta.json runs it (the title + screen entry comes in T2); `-- --tutorial` runs it on any world for now. + Progress is saved with the world. +- Chapter 1 (First tools) is in the data; chapters 2-5 come with T2. The + guide is **Old Tam**, the island's hermit (decided): the steps are in his + voice, with his name on the panel. A short, guided first world that teaches the survival loop by having you do it: gather, craft, chop, eat, hunt, cook, fish, keep a mixed diet, diff --git a/project.godot b/project.godot index e8fa06d..d89de6f 100644 --- a/project.godot +++ b/project.godot @@ -25,6 +25,7 @@ config/icon="res://icon.svg" Boot="*res://scripts/core/boot.gd" ItemDB="*res://scripts/core/item_db.gd" Actions="*res://scripts/core/actions.gd" +Guide="*res://scripts/core/guide.gd" Saves="*res://scripts/core/saves.gd" Net="*res://scripts/net/net.gd" WorldNet="*res://scripts/net/world_net.gd" diff --git a/scripts/core/actions.gd b/scripts/core/actions.gd index f9723e4..44f12dc 100644 --- a/scripts/core/actions.gd +++ b/scripts/core/actions.gd @@ -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() diff --git a/scripts/core/guide.gd b/scripts/core/guide.gd new file mode 100644 index 0000000..1da7a64 --- /dev/null +++ b/scripts/core/guide.gd @@ -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:" | "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() diff --git a/scripts/ui/guide.gd b/scripts/ui/guide.gd new file mode 100644 index 0000000..20411bc --- /dev/null +++ b/scripts/ui/guide.gd @@ -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 diff --git a/scripts/ui/hud.gd b/scripts/ui/hud.gd index c7092ae..7d4a6bc 100644 --- a/scripts/ui/hud.gd +++ b/scripts/ui/hud.gd @@ -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() diff --git a/scripts/world/world.gd b/scripts/world/world.gd index 5efd2dc..761150e 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -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: diff --git a/tests/run_all.sh b/tests/run_all.sh index 3a67492..fb00022 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then echo "Importing the project first (fresh copy)..." godot --headless --path . --import >/dev/null 2>&1 fi -for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_host test_terrain_edges test_boundary; do +for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_host test_tutorial test_terrain_edges test_boundary; do extra="" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30" # A suite that waits on a world that failed to load would hang forever, diff --git a/tests/test_tutorial.gd b/tests/test_tutorial.gd new file mode 100644 index 0000000..ee015f3 --- /dev/null +++ b/tests/test_tutorial.gd @@ -0,0 +1,223 @@ +extends SceneTree +## T1 (docs/TUTORIAL_DESIGN.md §7): the Guide, data/tutorial.json, Actions' +## events, the guide panel and the marker. Plays the first chapter by +## calling Actions, the way a player would, and checks every step ticks in +## order; a step done early ticks as soon as it comes up; progress is saved. +## Run: godot --headless --path . --script res://tests/test_tutorial.gd + +var _fails := 0 +var _finished := false +var A: Node +var S: Node +var DB: Node +var G: Node +var world: Node3D +var player: CharacterBody3D +var ticked: Array = [] + +func check(cond: bool, what: String) -> void: + print((" ok " if cond else " FAIL ") + what) + if not cond: + _fails += 1 + +func _initialize() -> void: + _run.call_deferred() + +func _run() -> void: + A = root.get_node("Actions") + S = root.get_node("Saves") + DB = root.get_node("ItemDB") + G = root.get_node("Guide") + G.step_done.connect(func(_i, s): ticked.append(String(s.id))) + S.root_dir = "user://test_worlds" + for w in S.list_worlds(): + S.delete_world(w.id) + S.current_id = S.create_world("Tutorial", 1337, {"tutorial": true}) + await _load() + await _body() + check(_finished, "test ran to the end (no script errors)") + for w in S.list_worlds(): + S.delete_world(w.id) + DirAccess.remove_absolute(S.root_dir) + print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) + quit(1 if _fails > 0 else 0) + +func _load() -> void: + world = load("res://scenes/world.tscn").instantiate() + root.add_child(world) + for _i in range(5): + await physics_frame + world.creatures.enabled = false + for c in world.creatures.alive(): + c.queue_free() + if world.wolves != null: + world.wolves.enabled = false + player = world.player + player.set_physics_process(false) + player.survival.set_process(false) + +func _reload() -> void: + check(world.save_now(), "saved") + world.queue_free() + await process_frame + await _load() + +## The guide panel. (Headless runs have no HUD, so the test makes one, as +## the HUD does.) +var _pn: Control = null +func _panel() -> Control: + if _pn == null or not is_instance_valid(_pn): + _pn = load("res://scripts/ui/guide.gd").new() + root.add_child(_pn) + _pn.setup(player) + return _pn + +## Stands next to the nearest `id` pickup and picks it up. Returns false if +## there's none. +func _take(id: String) -> bool: + var best: Node3D = null + var best_d := INF + for pk in world.props.pickups.values(): + if is_instance_valid(pk) and not pk.taken and pk.item_id == id: + var d: float = pk.global_position.distance_to(player.global_position) + if d < best_d: + best_d = d + best = pk + if best == null: + return false + player.global_position = best.global_position + Vector3(0.8, 0.0, 0) + return A.pickup(player, best) + +func _nearest_node(kind: String) -> Node3D: + var best: Node3D = null + var best_d := INF + for n in world.props.resource_nodes.values(): + if is_instance_valid(n) and n.kind == kind and not n.depleted: + var d: float = n.global_position.distance_to(player.global_position) + if d < best_d: + best_d = d + best = n + return best + +func _hold(id: String) -> void: + for i in range(player.inventory.size()): + var s = player.inventory.get_slot(i) + if s != null and s.id == id: + player.select(i) + return + +## Hits `n` until it's down (chopped or broken). +func _fell(n: Node3D) -> int: + var to: Vector3 = player.global_position - n.global_position + to.y = 0.0 + player.global_position = n.global_position + (to.normalized() if to.length() > 0.1 else Vector3(1, 0, 0)) * 1.4 + var hits := 0 + while not n.depleted and hits < 40: + player.survival.stamina = 100.0 + if A.harvest_hit(player, n, n.global_position + Vector3(0, 0.6, 0)): + hits += 1 + return hits + +func _body() -> void: + await _starts() + await _chapter_one() + await _saving() + _finished = true + +func _starts() -> void: + print("the tutorial starts") + check(G.active and G.world == world, "a tutorial world starts the guide") + check(G.steps.size() == 6 and G.index == 0 and String(G.current().id) == "look", "chapter 1: six steps, starting with Look around") + var pn := _panel() + check(pn != null and pn.visible and pn._box.visible, "the guide panel is up") + check(pn._title.text == "Look around" and pn._chapter.text.contains("First tools") and pn._chapter.text.contains("step 1 of 6"), + "\"%s\" / \"%s\"" % [pn._chapter.text, pn._title.text]) + check(G.guide_name == "Old Tam" and pn._text.text.begins_with("Old Tam: \""), "Old Tam the hermit talks you through it: %s" % pn._text.text) + check(G.find_marker() == Vector3.INF, "(no marker for that one)") + +func _chapter_one() -> void: + print("1. look around") + var start: Vector3 = player.global_position + for i in range(14): + player.global_position = start + Vector3(1.0 * (i + 1), 0, 0) + await process_frame + await process_frame + check(ticked == ["look"], "walk 10 m and it ticks (%s; walked %.0f m)" % [ticked, float(G.counters.get("walk", 0.0))]) + player.global_position = start + Vector3(0, 0, 50) + await process_frame + check(float(G.counters.get("walk", 0.0)) < 15.0, "(a jump isn't counted as walking)") + player.global_position = start + await process_frame + print("2. sticks and stones") + check(String(G.current().id) == "sticks", "next: %s" % G.current().get("title", "?")) + await process_frame + G._marker = G.find_marker() + var m: Vector3 = G.marker_position() + var near_stick := false + for pk in world.props.pickups.values(): + if pk.item_id == "stick" and not pk.taken and pk.global_position.distance_to(m) < 0.01: + near_stick = true + check(m != Vector3.INF and near_stick, "the marker stands on the nearest stick") + # Flax first, out of order: step 3 will tick the moment it comes up. + check(_take("fiber"), "(pick some flax early)") + check(_take("stick") and _take("stick"), "two sticks") + check(G.progress_text().contains("Sticks 2/3") and G.progress_text().contains("Stone 0/3"), "the panel counts them: \"%s\"" % G.progress_text()) + check(_panel()._progress.text == G.progress_text(), "(shown on the panel)") + check(_take("stick") and _take("stone") and _take("stone"), "a third, and two stones") + check(String(G.current().id) == "sticks", "not done yet (2 of 3 stones)") + check(_take("stone"), "the third stone") + check(ticked == ["look", "sticks", "flax"], "sticks and stones tick, and so does the flax picked earlier (%s)" % [ticked]) + print("4. the axe") + check(String(G.current().id) == "axe" and _panel()._title.text == "Craft a stone axe", "next: craft a stone axe") + G._marker = G.find_marker() + check(G.marker_position() != Vector3.INF, "the marker shows where flint is") + check(_take("flint") and _take("flint"), "two flint") + check(G.find_marker() == Vector3.INF, "with two in your bag, the marker goes") + check(A.craft(player, "stone_axe"), "craft it") + check(ticked.back() == "axe", "it ticks") + print("5. a tree") + G._marker = G.find_marker() + var tree := _nearest_node("tree") + check(G.marker_position().distance_to(tree.global_position) < 0.01, "the marker's on the nearest tree") + _hold("stone_axe") + var hits := _fell(tree) + check(tree.depleted and ticked.back() == "tree", "it ticks when the tree comes down (%d hits)" % hits) + print("6. pickaxe and boulder") + check(String(G.current().id) == "pickaxe", "next: the pickaxe") + while player.inventory.count("stick") < 2: + _take("stick") + if player.inventory.count("flint") < 1: + _take("flint") + check(A.craft(player, "stone_pickaxe"), "craft it") + check(String(G.current().id) == "pickaxe" and G.progress_text().contains("✓ Stone Pickaxe"), "half done: \"%s\"" % G.progress_text()) + var rock := _nearest_node("rock") + _hold("stone_pickaxe") + _fell(rock) + check(rock.depleted and ticked.back() == "pickaxe", "break a boulder and it ticks") + check(ticked == ["look", "sticks", "flax", "axe", "tree", "pickaxe"], "every step ticked, in order") + check(G.is_finished() and not _panel()._box.visible, "the chapter's done (and the panel steps aside)") + +func _saving() -> void: + print("saving") + var id: String = S.create_world("Tutorial 2", 1337, {"tutorial": true}) + world.queue_free() + await process_frame + S.current_id = id + ticked.clear() + await _load() + check(G.active and G.index == 0, "a fresh tutorial world starts at the beginning") + G.walked(12.0) + check(_take("stick") and _take("stone"), "a step and a bit in") + check(G.index == 1 and int(G.counters.get("gained:stick", 0)) == 1, "(on step 2, one stick so far)") + await _reload() + check(G.active and G.index == 1 and String(G.current().id) == "sticks", "after a reload it's still on step 2") + check(int(G.counters.get("gained:stick", 0)) == 1 and int(G.counters.get("gained:stone", 0)) == 1, "with what you'd gathered") + print("other worlds") + var plain: String = S.create_world("Ordinary", 1337) + world.queue_free() + await process_frame + S.current_id = plain + await _load() + _pn.queue_free() + _pn = null + check(not G.active and not _panel().visible, "an ordinary world has no tutorial")