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 Enter ("Continue") ## {"shelter": true} standing under a roof with walls round you ## {"bed_spawn": true} you've set your spawn at a bed ## {"demo_wolf": true} the tutorial's wolf has been and gone ## {"all": [goal, goal, ...]} every one of them ## {"any": [goal, goal, ...]} one of them ## Markers: {"nearest": "pickup:" | "tree" | "rock" | "bush" | "lake" | ## "creature:" | "carcass" | "piece:"}, optionally ## {"unless_have": {id: n}} to hide it once you've got enough. ## Set-up, when a step comes up: {"time": hours} (the clock jumps on), ## {"spawn": {"kind", "count", "distance"}} (calm animals round you), ## {"demo_wolf": true} (a wolf that circles and leaves: tutorial_wolf.gd). ## ## 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", "")) finish = d.get("finish", {}) ## Who's talking you through it (data/tutorial.json "guide": Old Tam, the ## island's hermit). var guide_name := "" var finish: Dictionary = {} ## the card at the end: title and text # --- 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: _clear_outline() Narrator.stop() 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() Narrator.say("tutorial_finish", String(finish.get("text", ""))) return _setup(steps[index].get("setup", {})) step_started.emit(index, steps[index]) Narrator.say("tutorial_" + String(steps[index].id), String(steps[index].text)) _marker_t = 0.0 _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: if su.is_empty() or world == null or not is_instance_valid(world): return if su.has("time"): var want: float = float(su.time) / 24.0 if world.day_night.time < want: world.day_night.time = want world.day_night.apply(true) if su.has("spawn"): var sp: Dictionary = su.spawn var p: Vector3 = world.player.global_position var rng := RandomNumberGenerator.new() rng.seed = int(p.x * 13.0 + p.z * 7.0) for i in range(int(sp.get("count", 1))): for _try in range(12): var a := rng.randf() * TAU var at := p + Vector3(cos(a), 0, sin(a)) * float(sp.get("distance", 12.0)) 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() demo_wolf = DemoWolf.new() demo_wolf.setup("wolf", world, world.player.global_position + Vector3(20, 0, 0), 4242) world.add_child(demo_wolf) demo_wolf.begin(world.player) demo_wolf.gone.connect(func(): _count("demo_wolf:true", 1)) ## 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: _clear_outline() # (it was only for the step that's ending) 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("any"): var names := [] var met := false for g in goal.any: for pp in parts(g): names.append(String(pp[0])) met = met or _met(g) return [[" or ".join(PackedStringArray(names)), 1.0 if met else 0.0, 1.0]] 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", "shelter", "bed_spawn", "demo_wolf"]: if goal.has(k): var key := "%s:%s" % [k, str(goal[k]).to_lower() if goal[k] is bool else str(goal[k])] # (true -> "true") 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", "shelter", "bed_spawn", "demo_wolf": return "" "placed": return ItemDB.item_name(String(goal.placed)) + " placed" "ate": return "Eaten" "killed": return String(goal.killed).capitalize() "butchered": return "Butchered" "caught": return "A fish" "slept": return "Slept" 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() poll() ## Goals that are states, not events: checked twice a second. func poll() -> void: if not active or world == null or not is_instance_valid(world): return var pl: Node = world.player if pl.survival.sheltered and float(counters.get("shelter:true", 0.0)) < 1.0: _count("shelter:true", 1) if world.has_bed_spawn and float(counters.get("bed_spawn:true", 0.0)) < 1.0: _count("bed_spawn:true", 1) ## 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). ## Whether the part of `goal` about `kind`:`id` (e.g. gained: stone) is done. func _part_met(goal: Dictionary, kind: String, id: String) -> bool: var parts: Array = goal.get("all", [goal]) for p in parts: if p is Dictionary and String(p.get(kind, "")) == id: return _met(p) return false 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:"): # "pickup:stick|stone": the nearest of whichever you still need (the # step's "gained" goal for that item isn't met yet). var ids: Array = [] for id in what.substr(7).split("|", false): if not _part_met(s.get("goal", {}), "gained", id): ids.append(id) for pk in world.props.pickups.values(): if is_instance_valid(pk) and not pk.taken and pk.item_id in ids: 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) elif what == "bush": for b in world.props.forage.values(): if is_instance_valid(b) and b.ripe(): cands.append(b) elif what.begins_with("creature:"): for c in world.creatures.alive(what.substr(9)): cands.append(c) elif what == "carcass": for c in world.get_tree().get_nodes_in_group("carcass"): cands.append(c) elif what.begins_with("piece:"): 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 var dir := Vector2(from.x - c.x, from.z - c.y) dir = dir.normalized() if dir.length() > 0.1 else Vector2(1, 0) var r: float = world.terrain.lake_radius * 0.6 while r < world.terrain.lake_radius * 2.0 and world.terrain.sample_height(c.x + dir.x * r, c.y + dir.y * r) < world.terrain.water_level + 0.3: r += 0.5 var sx: float = c.x + dir.x * r var sz: float = c.y + dir.y * r return Vector3(sx, world.terrain.sample_height(sx, sz), sz) 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()