diff --git a/data/tutorial.json b/data/tutorial.json index c9172c6..afc24ce 100644 --- a/data/tutorial.json +++ b/data/tutorial.json @@ -4,7 +4,26 @@ "about": "the hermit who's lived on this island longer than he'll admit" }, "chapters": [ - {"id": "tools", "title": "First tools"} + { + "id": "tools", + "title": "First tools" + }, + { + "id": "food", + "title": "Fire and food" + }, + { + "id": "hunt", + "title": "Hunting" + }, + { + "id": "fish", + "title": "Fishing" + }, + { + "id": "night", + "title": "Shelter and night" + } ], "steps": [ { @@ -12,47 +31,320 @@ "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} + "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}]} + "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} + "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"} + "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"} + "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"}]} + "marker": { + "nearest": "rock" + }, + "goal": { + "all": [ + { + "crafted": "stone_pickaxe" + }, + { + "felled": "rock" + } + ] + } + }, + { + "id": "campfire", + "chapter": "food", + "title": "Build a campfire", + "text": "Five stone and four sticks make a campfire. Pick it in your pack, then place it with the left button (R turns it). Fire gives light, warmth and somewhere to cook. Feed it wood with E.", + "goal": { + "placed": "campfire" + } + }, + { + "id": "berries", + "chapter": "food", + "title": "Pick berries and eat some", + "text": "Those red berries on the bushes are safe. Pick some with E, then hold them and press the right button to eat.", + "goal": { + "all": [ + { + "gained": "berries" + }, + { + "ate": "berries" + } + ] + }, + "marker": { + "nearest": "bush" + } + }, + { + "id": "mushroom", + "chapter": "food", + "title": "Roast a mushroom", + "text": "Mushrooms grow under the trees. Raw ones are all right; roasted at the fire, much better. Find one and roast it (stand by the fire and open your pack).", + "goal": { + "all": [ + { + "gained": "mushroom" + }, + { + "crafted": "roasted_mushroom" + } + ] + }, + "marker": { + "nearest": "pickup:mushroom", + "unless_have": { + "mushroom": 1 + } + } + }, + { + "id": "diet", + "chapter": "food", + "title": "Mind your diet", + "text": "See the three Diet bars, bottom left? Meat, Fish, Greens. Each food fills one. Let one run low and you stop healing; let two, and you'll waste away on a full belly. Keep all three up and you heal faster. Press Enter when you've got that.", + "goal": { + "continue": true + } + }, + { + "id": "workbench", + "chapter": "hunt", + "title": "Build a workbench", + "text": "Better tools want a workbench nearby. Ten wood and two flint. Chop a few more trees if you're short.", + "goal": { + "placed": "workbench" + } + }, + { + "id": "weapon", + "chapter": "hunt", + "title": "Make a weapon", + "text": "You'll not catch your supper with your bare hands. A club is four wood. A spear, at the bench, reaches further.", + "goal": { + "any": [ + { + "crafted": "club" + }, + { + "crafted": "spear" + } + ] + } + }, + { + "id": "rabbit", + "chapter": "hunt", + "title": "Hunt a rabbit", + "text": "There, rabbits! They bolt if you run at them. Walk up quiet, weapon in hand, and strike.", + "goal": { + "killed": "rabbit" + }, + "marker": { + "nearest": "creature:rabbit" + }, + "setup": { + "spawn": { + "kind": "rabbit", + "count": 3, + "distance": 14.0 + } + } + }, + { + "id": "cook", + "chapter": "hunt", + "title": "Butcher it and cook the meat", + "text": "E on the carcass to butcher it. Raw meat turns your stomach; cooked at the fire it heals you and keeps you Well fed.", + "goal": { + "all": [ + { + "butchered": "rabbit" + }, + { + "crafted": "cooked_meat" + } + ] + }, + "marker": { + "nearest": "carcass" + } + }, + { + "id": "animals", + "chapter": "hunt", + "title": "Know your animals", + "text": "Deer are quick: a bow's your friend there. Boars are calm until you hit one, then they come for you. Wolves you'll meet soon enough. Press Enter.", + "goal": { + "continue": true + } + }, + { + "id": "rod", + "chapter": "fish", + "title": "Make a fishing rod", + "text": "Two sticks, three fibre and a flint, at the workbench.", + "goal": { + "crafted": "fishing_rod" + } + }, + { + "id": "fish", + "chapter": "fish", + "title": "Catch a fish and grill it", + "text": "Down to the lake. Rod in hand, the left button casts. When the float dips, click quick, before it gets away. Grill what you catch at the fire.", + "goal": { + "all": [ + { + "caught": "any" + }, + { + "any": [ + { + "crafted": "grilled_perch" + }, + { + "crafted": "grilled_trout" + } + ] + } + ] + }, + "marker": { + "nearest": "lake" + } + }, + { + "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.", + "goal": { + "shelter": true + } + }, + { + "id": "bed", + "chapter": "night", + "title": "Make a bed and sleep spot", + "text": "Eight wood and six fibre make a bed. Put it inside, then E on it: that's where you'll wake if anything happens to you.", + "goal": { + "all": [ + { + "placed": "bed" + }, + { + "bed_spawn": true + } + ] + } + }, + { + "id": "torch", + "chapter": "night", + "title": "Night is coming: make a torch", + "text": "There, the sun's going down. It gets dark and cold. A torch (a stick and two fibre) lights the way and keeps you a little warm.", + "goal": { + "crafted": "torch" + }, + "setup": { + "time": 18.6 + } + }, + { + "id": "wolf", + "chapter": "night", + "title": "Wolves", + "text": "Hush. See it, at the edge of the light? Wolves. From the fourth night they hunt. They won't come close to a lit fire, a torch makes them think twice, and they can't bite through walls. Just watch.", + "goal": { + "demo_wolf": true + }, + "setup": { + "demo_wolf": true + } + }, + { + "id": "sleep", + "chapter": "night", + "title": "Sleep till morning", + "text": "Off to bed with you. You can only sleep at night, under a roof with walls round you, and not with wolves about.", + "goal": { + "slept": true + }, + "setup": { + "time": 21.0 + } } - ] + ], + "finish": { + "title": "You'll do", + "text": "That's the island, near enough: gather, craft, eat a bit of everything, keep warm and dry, and be indoors or by a fire when the wolves are about. E picks up and uses, Tab is your pack, X takes a thing down, R turns what you're placing, the right button eats or wears. Off you go, then." + } } diff --git a/docs/TUTORIAL_DESIGN.md b/docs/TUTORIAL_DESIGN.md index d0b8166..877fa6a 100644 --- a/docs/TUTORIAL_DESIGN.md +++ b/docs/TUTORIAL_DESIGN.md @@ -1,7 +1,33 @@ # Tutorial world design -Status: **T1 built** (the Guide, Actions' events, chapter 1 in -data/tutorial.json, the guide panel and marker); T2-T4 are proposals. +Status: **T1 and T2 built**: the whole tutorial, on the Valley island +(seed 1337), with Old Tam the hermit. T3 (its own island) and T4 (hints in +real worlds) are proposals. + +*As built in T2:* +- All five chapters, 22 steps (data/tutorial.json), in Old Tam's voice: + first tools; fire and food (campfire, berries, a roasted mushroom, the + diet bars); hunting (workbench, a weapon, a rabbit, butcher and cook, + a tip about the other animals); fishing (rod, a catch, grilled); shelter + and night (a hut you can stand inside, a bed and spawn, nightfall and a + torch, the demo wolf, sleeping). +- More goals: `any` (club or spear, perch or trout), `shelter`, + `bed_spawn`, `demo_wolf`, `continue` (Enter on the reading steps). +- Step set-ups: the clock jumps to dusk (18:36) and to bedtime (21:00); + three rabbits appear 14 m off for the hunt; the demo wolf + (scripts/creatures/tutorial_wolf.gd) circles at 11 m for 18 s and slinks + off, never biting. +- Markers for berry bushes, rabbits, the carcass, the lake shore. +- The tutorial island is calm: no wolf pack, no bear, no thieves. +- Title screen: **Tutorial** above New World (it says "carry on" once + started; with no worlds yet: "New here? Try the tutorial"). Pause menu: + Skip this step, Leave the tutorial (saved, carry on later). +- The end: Old Tam's goodbye card, **Start your own world** (to the New + World page) or **Keep exploring**. +- 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. *As built in T1:* - Actions has signals for what players do: `crafted`, `harvested` (with diff --git a/scripts/core/guide.gd b/scripts/core/guide.gd index 1da7a64..acf45c7 100644 --- a/scripts/core/guide.gd +++ b/scripts/core/guide.gd @@ -14,10 +14,18 @@ extends Node ## {"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 +## {"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 -## Markers: {"nearest": "pickup:" | "tree" | "rock"}, optionally +## {"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. @@ -90,10 +98,12 @@ func _load_data() -> void: 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 ----------------------------------------------------------------------- @@ -129,10 +139,44 @@ func _enter() -> void: if index >= steps.size(): finished.emit() return + _setup(steps[index].get("setup", {})) step_started.emit(index, steps[index]) _marker_t = 0.0 _check() # (done early? it ticks straight away) +const DemoWolf = preload("res://scripts/creatures/tutorial_wolf.gd") +var demo_wolf: Node3D = 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 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) @@ -168,15 +212,24 @@ func _advance() -> void: ## [[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"]: + 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, String(goal[k]).to_lower() if goal[k] is bool else String(goal[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)]] @@ -191,7 +244,13 @@ func _label(kind: String, goal: Dictionary) -> String: 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 "" + "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: @@ -233,6 +292,17 @@ func _process(delta: float) -> void: 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: @@ -265,6 +335,31 @@ func find_marker() -> Vector3: 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 == "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: diff --git a/scripts/creatures/tutorial_wolf.gd b/scripts/creatures/tutorial_wolf.gd new file mode 100644 index 0000000..6396f0e --- /dev/null +++ b/scripts/creatures/tutorial_wolf.gd @@ -0,0 +1,79 @@ +extends "res://scripts/creatures/creature.gd" +## The tutorial's wolf (docs/TUTORIAL_DESIGN.md §2, step "Wolves"): it comes +## out of the dark, circles at the edge of the light for a while, and slinks +## off. It never attacks. When it's gone, `gone` fires and the step ticks. + +signal gone + +const RING := 11.0 +const CIRCLE_SECONDS := 18.0 +const LEAVE_AFTER := 40.0 ## this far from you it's gone + +var target: Node3D +var _a := 0.0 +var _goal_d := 0.0 +var _done := false + +func begin(t: Node3D) -> void: + target = t + var away: Vector3 = global_position - t.global_position + _a = atan2(away.z, away.x) + state = "stalk" + _state_t = CIRCLE_SECONDS + +func display_name() -> String: + return "Wolf" + +func interact_prompt(_p: Node) -> String: + return "Wolf (watching you from the dark)" + +func _pick_target() -> Node3D: + return target + +func _think(dt: float) -> void: + if target == null or not is_instance_valid(target): + _leave() + return + _state_t -= dt + var tp: Vector3 = target.global_position + match state: + "stalk": + _a += dt * 0.35 + _go_to(tp + Vector3(cos(_a), 0, sin(_a)) * RING) + if _state_t <= 0.0: + state = "flee" + _state_t = 15.0 + "flee": + var away := global_position - tp + away.y = 0.0 + steer(atan2(-away.x, -away.z)) + _goal_d = 99.0 + if global_position.distance_to(tp) > LEAVE_AFTER or _state_t <= 0.0: + _leave() + _: + state = "stalk" + +func _go_to(p: Vector3) -> void: + var to := p - global_position + to.y = 0.0 + if to.length() > 0.3: + steer(atan2(-to.x, -to.z)) + _goal_d = to.length() + +func _desired_speed() -> float: + match state: + "stalk": return 0.0 if _goal_d < 1.0 else (3.0 if _goal_d < 6.0 else 6.5) + "flee": return 7.5 + return 0.0 + +func _leave() -> void: + if _done: + return + _done = true + gone.emit() + queue_free() + +## (A swipe makes it run.) +func _react(_from: Vector3) -> void: + state = "flee" + _state_t = 10.0 diff --git a/scripts/ui/guide.gd b/scripts/ui/guide.gd index 20411bc..82cdbd7 100644 --- a/scripts/ui/guide.gd +++ b/scripts/ui/guide.gd @@ -61,9 +61,14 @@ func setup(p: Node3D) -> void: _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: + if not Guide.active or not (event is InputEventKey) or not event.pressed or event.echo: + return + if event.keycode == KEY_H: _hidden = not _hidden _redraw() + elif event.keycode in [KEY_ENTER, KEY_KP_ENTER] and Guide.current().get("goal", {}).has("continue"): + Guide.continue_step() + get_viewport().set_input_as_handled() func _redraw() -> void: var s: Dictionary = Guide.current() @@ -84,13 +89,62 @@ func _on_done(_i: int, s: Dictionary) -> void: tw.tween_interval(1.4) tw.tween_property(_tick, "modulate:a", 0.0, 0.6) +## The end: Old Tam's summary, and Start your own world / Keep exploring. +var card: PanelContainer + 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) + if card != null and is_instance_valid(card): + return + card = UITheme.panel() + card.set_anchors_and_offsets_preset(Control.PRESET_CENTER) + card.grow_horizontal = Control.GROW_DIRECTION_BOTH + card.grow_vertical = Control.GROW_DIRECTION_BOTH + card.mouse_filter = Control.MOUSE_FILTER_STOP + add_child(card) + var v := VBoxContainer.new() + v.add_theme_constant_override("separation", 10) + v.custom_minimum_size = Vector2(520, 0) + card.add_child(v) + var f: Dictionary = Guide.finish + v.add_child(UITheme.label(String(f.get("title", "Tutorial done")), 26)) + var t := UITheme.label("%s: \"%s\"" % [Guide.guide_name, String(f.get("text", ""))] if not Guide.guide_name.is_empty() else String(f.get("text", "")), 16, UITheme.TEXT_DIM) + t.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + t.custom_minimum_size = Vector2(520, 0) + v.add_child(t) + var h := HBoxContainer.new() + h.add_theme_constant_override("separation", 10) + v.add_child(h) + for b in [["Start your own world", _on_new_world], ["Keep exploring", _on_keep_exploring]]: + var btn := Button.new() + btn.text = b[0] + btn.focus_mode = Control.FOCUS_NONE + btn.custom_minimum_size = Vector2(240, 44) + btn.add_theme_font_size_override("font_size", 18) + btn.pressed.connect(b[1]) + h.add_child(btn) + if player != null and player.get("ui_open") != null: + player.ui_open = true + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + +func _on_keep_exploring() -> void: + if card != null: + card.queue_free() + card = null + if player != null and player.get("ui_open") != null: + player.ui_open = false + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + +## Saves the tutorial world and goes to the title screen's New World page. +func _on_new_world() -> void: + if Guide.world != null and is_instance_valid(Guide.world): + Guide.world.save_now() + get_tree().paused = false + Saves.current_id = "" + get_tree().auto_accept_quit = true + var TS: Script = load("res://scripts/ui/title_screen.gd") + TS.start_page = "new" + get_tree().change_scene_to_file("res://scenes/title.tscn") func _process(_delta: float) -> void: _update_marker() diff --git a/scripts/ui/pause_menu.gd b/scripts/ui/pause_menu.gd index 9bdfd7e..b431af8 100644 --- a/scripts/ui/pause_menu.gd +++ b/scripts/ui/pause_menu.gd @@ -80,6 +80,11 @@ func bind(p: CharacterBody3D) -> void: sens_row.add_child(sens) _main.add_child(sens_row) + # The tutorial: skip a step, or leave it (saved, to carry on later). + if Guide.active: + _main.add_child(_button("Skip this tutorial step", func(): Guide.skip(); close())) + _main.add_child(_button("Leave the tutorial (carry on later)", _on_quit_to_title)) + var saved: bool = Saves.has_world() _online = Net.state == "connected" if _online: diff --git a/scripts/ui/title_screen.gd b/scripts/ui/title_screen.gd index b1a855d..b2106d4 100644 --- a/scripts/ui/title_screen.gd +++ b/scripts/ui/title_screen.gd @@ -9,6 +9,8 @@ var _pages: Dictionary = {} ## name -> Control var _name_edit: LineEdit var _seed_edit: LineEdit var _rot_pick: OptionButton +## The page to open on (the tutorial's "Start your own world" sends you to "new"). +static var start_page := "" ## [setting, what the New World page calls it] const ROT_CHOICES := [["normal", "Normally (raw meat lasts 2 days)"], ["slow", "Slowly (twice as long)"], ["off", "Never"]] var _list: VBoxContainer @@ -100,7 +102,8 @@ func _ready() -> void: Net.players_changed.connect(func(): if _pages["lobby"].visible: _fill_lobby()) - _show("main") + _show(start_page if start_page in _pages else "main") + start_page = "" # Back from a server (left, kicked, or it went away): say why. if Net.state == "offline" and not Net.last_reason.is_empty(): _show("join") @@ -202,9 +205,13 @@ func _main_page() -> Control: var v := VBoxContainer.new() v.add_theme_constant_override("separation", 10) var worlds := Saves.list_worlds() - if not worlds.is_empty(): + if worlds.is_empty(): + v.add_child(UITheme.label("New here? Try the tutorial (about 20 minutes).", 15, UITheme.TEXT_DIM)) + else: var w: Dictionary = worlds[0] v.add_child(_button("Continue: %s" % w.name, _play.bind(w.id))) + var tut := tutorial_world() + v.add_child(_button("Tutorial" if tut.is_empty() else "Tutorial (carry on)", _play_tutorial)) v.add_child(_button("New World", _show.bind("new"))) var lb := _button("Load World", _show.bind("load")) lb.disabled = worlds.is_empty() @@ -673,6 +680,20 @@ func _create() -> void: var rot: String = ROT_CHOICES[maxi(_rot_pick.selected, 0)][0] _play(Saves.create_world(world_name, seed, {"rot": rot})) +## The tutorial (docs/TUTORIAL_DESIGN.md): its own world on the original +## island (seed 1337), made the first time and carried on after that. +const TUTORIAL_SEED := 1337 + +static func tutorial_world() -> Dictionary: + for w in Saves.list_worlds(): + if bool(w.get("tutorial", false)): + return w + return {} + +func _play_tutorial() -> void: + var w := tutorial_world() + _play(String(w.id) if not w.is_empty() else Saves.create_world("Tutorial", TUTORIAL_SEED, {"tutorial": true})) + func _play(id: String) -> void: Saves.current_id = id get_tree().change_scene_to_file(WORLD_SCENE) diff --git a/scripts/world/world.gd b/scripts/world/world.gd index 761150e..f47561b 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -416,11 +416,15 @@ func _on_terrain_ready() -> void: wolves.name = "Wolves" add_child(wolves) 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(): + # "tutorial"), or any world with `-- --tutorial`. Its island is calm: no + # wolf pack (just Old Tam's demo wolf), no bear, nothing stealing your food. + var tutorial := bool(Saves.read_meta(Saves.current_id).get("tutorial", false)) or "--tutorial" in OS.get_cmdline_user_args() + if tutorial and wolves != null: + wolves.enabled = false + if not "--no-creatures" in OS.get_cmdline_user_args() and not tutorial: + _setup_bear() + if tutorial: Guide.start(self, _saved_tutorial) _maybe_capture() diff --git a/tests/run_all.sh b/tests/run_all.sh index fb00022..61ba18e 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_tutorial 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_tutorial_full 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 index ee015f3..66576d0 100644 --- a/tests/test_tutorial.gd +++ b/tests/test_tutorial.gd @@ -127,10 +127,11 @@ func _body() -> void: 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 ch1: int = G.steps.filter(func(st): return st.chapter == "tools").size() + check(ch1 == 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"), + check(pn._title.text == "Look around" and pn._chapter.text.contains("First tools") and pn._chapter.text.contains("step 1 of %d" % G.steps.size()), "\"%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)") @@ -195,7 +196,7 @@ func _chapter_one() -> void: _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)") + check(String(G.current().get("chapter", "")) == "food" and _panel()._chapter.text.contains("Fire and food"), "chapter 1's done: on to Fire and food") func _saving() -> void: print("saving") diff --git a/tests/test_tutorial_full.gd b/tests/test_tutorial_full.gd new file mode 100644 index 0000000..69c74d9 --- /dev/null +++ b/tests/test_tutorial_full.gd @@ -0,0 +1,363 @@ +extends SceneTree +## T2 (docs/TUTORIAL_DESIGN.md §7): the whole tutorial, every chapter, played +## by a bot through Actions on the tutorial world (the Valley island, seed +## 1337): first tools, fire and food, hunting, fishing, shelter and night +## (with the demo wolf), sleeping, and the finish card. Materials it would +## take a while to gather by hand (wood for a hut, say) are put in the bag; +## every goal itself is done the way a player does it. +## Run: godot --headless --path . --script res://tests/test_tutorial_full.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 inv: Inventory +var sv: Node +var ticked: Array = [] +var fire: Node3D +var bench: Node3D +var _pn: Control + +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))) + A.food_rng.seed = 3 # (so raw food doesn't make the bot ill at a bad moment) + S.root_dir = "user://test_worlds" + for w in S.list_worlds(): + S.delete_world(w.id) + var TS: Script = load("res://scripts/ui/title_screen.gd") + check(TS.tutorial_world().is_empty(), "no tutorial world yet") + S.current_id = S.create_world("Tutorial", TS.TUTORIAL_SEED, {"tutorial": true}) + check(not TS.tutorial_world().is_empty(), "the title screen finds it once it's made (Tutorial, carry on)") + world = load("res://scenes/world.tscn").instantiate() + root.add_child(world) + for _i in range(5): + await physics_frame + player = world.player + player.set_physics_process(false) + player.survival.set_process(false) + world.day_night.frozen = true + world.weather.set_process(false) + world.weather.force("clear") + inv = player.inventory + sv = player.survival + _pn = load("res://scripts/ui/guide.gd").new() + root.add_child(_pn) + _pn.setup(player) + 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) + +# --- helpers ------------------------------------------------------------------------------ + +func _at(id: String) -> bool: + return String(G.current().get("id", "")) == id + +func _ground(p: Vector3) -> Vector3: + return Vector3(p.x, world.terrain.sample_height(p.x, p.z), p.z) + +func _near(p: Vector3, d: float = 1.2) -> void: + var to: Vector3 = player.global_position - p + to.y = 0.0 + player.global_position = _ground(p + (to.normalized() if to.length() > 0.1 else Vector3(1, 0, 0)) * d) + +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 _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 + +## Puts `id` in hand: swapped into the first hotbar slot (only those can be +## held), like a player dragging it there. +func _hold(id: String) -> int: + for i in range(inv.size()): + var s = inv.get_slot(i) + if s != null and s.id == id: + var tmp = inv.get_slot(0) + inv.set_slot(0, s) + inv.set_slot(i, tmp) + player.select(0) + return 0 + return -1 + +func _fell(n: Node3D) -> void: + _near(n.global_position, 1.4) + var hits := 0 + while not n.depleted and hits < 40: + sv.stamina = 100.0 + if A.harvest_hit(player, n, n.global_position + Vector3(0, 0.6, 0)): + hits += 1 + +## Places what's in `id`'s slot on the ground `ahead` metres in front of you. +func _place(id: String, ahead: float, side: float = 0.0) -> Node3D: + var slot := _hold(id) + var f := -player.global_basis.z + var r := player.global_basis.x + for k in range(12): + var at := _ground(player.global_position + f * (ahead + 0.5 * k) + r * side) + var xf := Transform3D(Basis(Vector3.UP, player.rotation.y), at) + if world.placement_valid(DB.get_item(id), xf) and A.place(player, slot, xf, world): + for p in world.all_pieces(): + if p.item_id == id and p.global_position.distance_to(at) < 0.05: + return p + return null + +func _by_fire() -> void: + _near(fire.global_position, 2.0) + +func _step_done(id: String) -> bool: + return ticked.has(id) + +func _body() -> void: + await _tools() + await _food() + await _hunt() + await _fishing() + await _night() + _finish() + _finished = true + +# --- 1. first tools ------------------------------------------------------------------------- + +func _tools() -> void: + print("1. first tools") + check(G.active and _at("look"), "the tutorial world starts the tutorial (Old Tam: \"%s\")" % String(G.current().text).left(40)) + check(world.wolves == null or not world.wolves.enabled, "a calm island: no wolf pack") + check(world.bears == null, "no bear, nothing stealing food") + G.walked(11.0) + for id in ["stick", "stick", "stick", "stone", "stone", "stone", "fiber", "flint", "flint"]: + _take(id) + check(A.craft(player, "stone_axe"), "an axe") + _hold("stone_axe") + _fell(_node("tree")) + while inv.count("stick") < 2: + _take("stick") + if inv.count("flint") < 1: + _take("flint") + check(A.craft(player, "stone_pickaxe"), "a pickaxe") + _hold("stone_pickaxe") + _fell(_node("rock")) + check(ticked == ["look", "sticks", "flax", "axe", "tree", "pickaxe"], "chapter 1 done: %s" % [ticked]) + +# --- 2. fire and food ----------------------------------------------------------------------- + +func _food() -> void: + print("2. fire and food") + check(_at("campfire") and _pn._chapter.text.contains("Fire and food"), "next chapter: Fire and food") + inv.add("stone", 5) + inv.add("stick", 4) + check(A.craft(player, "campfire"), "a campfire, by hand") + fire = _place("campfire", 2.5) + check(fire != null and _step_done("campfire"), "placed: it ticks") + fire.fuel = fire.MAX_FUEL + print(" berries") + G._marker = G.find_marker() + var bush: Node3D = null + for b in world.props.forage.values(): + if is_instance_valid(b) and b.ripe() and (bush == null or b.global_position.distance_to(player.global_position) < bush.global_position.distance_to(player.global_position)): + bush = b + check(bush != null and G.marker_position().distance_to(bush.global_position) < 0.05, "the marker's on the nearest ripe bush") + _near(bush.global_position, 1.2) + check(A.forage(player, bush) and inv.count("berries") > 0, "picked") + sv.hunger = 40.0 + check(A.eat(player, _hold("berries")) and _step_done("berries"), "and eaten: it ticks") + print(" a mushroom") + check(_take("mushroom"), "found one under the trees") + _by_fire() + check(A.craft(player, "roasted_mushroom") and _step_done("mushroom"), "roasted at the fire: it ticks") + print(" the diet") + check(_at("diet") and _pn._progress.text.is_empty(), "Old Tam explains the diet bars") + var ev := InputEventKey.new() + ev.keycode = KEY_ENTER + ev.pressed = true + _pn._unhandled_input(ev) + check(_step_done("diet"), "Enter carries on") + +# --- 3. hunting ------------------------------------------------------------------------------ + +func _hunt() -> void: + print("3. hunting") + _by_fire() + inv.add("wood", 14) + inv.add("flint", 2) + check(A.craft(player, "workbench"), "a workbench") + bench = _place("workbench", 2.5, 2.5) + check(bench != null and _step_done("workbench"), "placed") + check(A.craft(player, "club") and _step_done("weapon"), "a club: a weapon") + check(_at("rabbit"), "now: %s" % G.current().get("title", "?")) + var rabbits: Array = world.creatures.alive("rabbit") + check(rabbits.size() >= 3, "Old Tam's rabbits are about (%d)" % rabbits.size()) + G._marker = G.find_marker() + var rb: Node3D = null + for r in rabbits: + if rb == null or r.global_position.distance_to(player.global_position) < rb.global_position.distance_to(player.global_position): + rb = r + check(G.marker_position().distance_to(rb.global_position) < 0.05, "the marker's on the nearest one") + rb.set_physics_process(false) + _near(rb.global_position, 1.5) + _hold("club") + sv.stamina = 100.0 + check(A.melee(player, rb) and rb.is_dead() and _step_done("rabbit"), "one blow with the club") + check(A.butcher(player, rb), "butchered") + _by_fire() + check(A.craft(player, "cooked_meat") and _step_done("cook"), "and the meat cooked: it ticks") + G.continue_step() + check(_step_done("animals"), "(the tip about deer and boars)") + +# --- 4. fishing -------------------------------------------------------------------------------- + +func _fishing() -> void: + print("4. fishing") + _near(bench.global_position, 1.5) + inv.add("stick", 2) + inv.add("fiber", 3) + inv.add("flint", 1) + check(A.craft(player, "fishing_rod") and _step_done("rod"), "a rod at the workbench") + G._marker = G.find_marker() + var shore: Vector3 = G.marker_position() + var t: Node3D = world.terrain + check(shore != Vector3.INF and t.sample_height(shore.x, shore.z) >= t.water_level, "the marker shows the lake shore") + var lake: Vector2 = t.lake_center + var x: float = lake.x + while t.sample_height(x, lake.y) < t.water_level + 0.4 and x < lake.x + 60.0: + x += 0.5 + player.global_position = _ground(Vector3(x + 1.0, 0, lake.y)) + _hold("fishing_rod") + sv.stamina = 100.0 + var origin: Vector3 = player.global_position + Vector3(0, 1.6, 0) + check(A.cast_line(player, origin, Vector3(lake.x, t.water_level, lake.y) - origin, world), "cast") + var tt := 0.0 + while tt < 400.0: + var b: Node3D = world.bobber + if b == null or b.state == "biting": + break + b.set_physics_process(false) + b.step(0.05) + tt += 0.05 + check(world.bobber != null and world.bobber.state == "biting", "a bite (after %.0f s)" % tt) + check(A.reel(player, world), "landed it") + var fish := "perch" if inv.count("perch") > 0 else "trout" + _by_fire() + check(A.craft(player, "grilled_" + fish) and _step_done("fish"), "grilled: it ticks") + +# --- 5. shelter and night ---------------------------------------------------------------------- + +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) + for _i in range(3): + await physics_frame + check(hut.placed == 20, "a hut (the same snapping a player uses: %d pieces)" % hut.placed) + var b := Basis(Vector3.UP, yaw) + var center: Vector3 = hut.pieces[0].global_transform * Vector3(1, 0, 1) + player.global_position = center + b * Vector3(0.6, 0.05, -0.6) + sv._shelter_left = 0.0 + sv.step(0.2) + G.poll() + check(_step_done("hut"), "stand inside out of the weather: it ticks") + print(" the bed") + _near(bench.global_position, 1.5) + inv.add("wood", 8) + inv.add("fiber", 6) + check(A.craft(player, "bed"), "a bed at the workbench") + player.global_position = center + b * Vector3(0.6, 0.05, 0.9) + var slot := _hold("bed") + var bxf := Transform3D(b, center + b * Vector3(-0.5, 0, -0.4)) + check(world.placement_valid(DB.get_item("bed"), bxf) and A.place(player, slot, bxf, world), "put inside") + var bed: Node3D = null + for p in world.all_pieces(): + if p.item_id == "bed": + bed = p + check(A.use_bed(player, bed), "E on it: spawn set") + G.poll() + check(_step_done("bed"), "it ticks") + print(" nightfall") + check(_at("torch") and world.day_night.hours() >= 18.5, "the sun goes down (%.1f h)" % world.day_night.hours()) + inv.add("stick", 1) + inv.add("fiber", 2) + check(A.craft(player, "torch") and _step_done("torch"), "a torch") + print(" the wolf") + check(_at("wolf") and G.demo_wolf != null, "a wolf comes out of the dark") + var w: Node3D = G.demo_wolf + var hp0: float = sv.health + var closest := INF + var t := 0.0 + while is_instance_valid(w) and not w.is_queued_for_deletion() and t < 60.0: + w.set_physics_process(false) + w.step(0.1) + closest = minf(closest, w.global_position.distance_to(player.global_position)) + t += 0.1 + if int(t * 10) % 10 == 0: + await physics_frame + check(_step_done("wolf"), "it circles, then slinks off (%.0f s)" % t) + check(sv.health == hp0 and closest > 6.0, "never closer than %.0f m, and never bites" % closest) + print(" sleep") + check(_at("sleep") and world.day_night.hours() >= 20.9, "bedtime (%.1f h)" % world.day_night.hours()) + player.global_position = center + b * Vector3(0.6, 0.05, 0.9) + check(A.use_bed(player, bed), "into bed") + check(_step_done("sleep"), "slept till morning: it ticks") + +func _finish() -> void: + print("finished") + var order := [] + for s in G.steps: + order.append(String(s.id)) + check(ticked == order, "all %d steps ticked, in order" % order.size()) + check(G.is_finished(), "the tutorial is done") + check(_pn.card != null and is_instance_valid(_pn.card), "Old Tam's goodbye card is up") + var buttons: Array = _pn.card.find_children("*", "Button", true, false) + check(buttons.size() == 2 and buttons[0].text == "Start your own world" and buttons[1].text == "Keep exploring", + "with Start your own world and Keep exploring") + _pn._on_keep_exploring() + check(_pn.card == null and not player.ui_open, "Keep exploring: back to the island") + print("skipping") + # (With nothing counted yet, so the next step doesn't tick at once.) + var idx: int = G.index + var saved: Dictionary = G.counters.duplicate() + G.counters = {} + G.index = 3 + G.skip() + check(G.index == 4 and String(G.current().id) == "tree", "(Skip step moves on: the pause menu's button)") + G.index = idx + G.counters = saved