T1 tutorial: Actions events, the Guide autoload, chapter 1 with Old Tam the hermit, guide panel and marker, saved progress; roadmap: auto-update plan
This commit is contained in:
58
data/tutorial.json
Normal file
58
data/tutorial.json
Normal file
@@ -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"}]}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -88,6 +88,36 @@ phases M2.0-M2.7).
|
|||||||
- Better UI, settings, controls
|
- Better UI, settings, controls
|
||||||
- Audio, music, weather
|
- Audio, music, weather
|
||||||
- Packaged builds for Win/Mac/Linux (+ Mac signing/notarisation)
|
- 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
|
## Honest overall estimate
|
||||||
A genuinely playable **self-hosted survival game with a working sandboxed addon
|
A genuinely playable **self-hosted survival game with a working sandboxed addon
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
# Tutorial world design
|
# 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
|
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,
|
do it: gather, craft, chop, eat, hunt, cook, fish, keep a mixed diet,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ config/icon="res://icon.svg"
|
|||||||
Boot="*res://scripts/core/boot.gd"
|
Boot="*res://scripts/core/boot.gd"
|
||||||
ItemDB="*res://scripts/core/item_db.gd"
|
ItemDB="*res://scripts/core/item_db.gd"
|
||||||
Actions="*res://scripts/core/actions.gd"
|
Actions="*res://scripts/core/actions.gd"
|
||||||
|
Guide="*res://scripts/core/guide.gd"
|
||||||
Saves="*res://scripts/core/saves.gd"
|
Saves="*res://scripts/core/saves.gd"
|
||||||
Net="*res://scripts/net/net.gd"
|
Net="*res://scripts/net/net.gd"
|
||||||
WorldNet="*res://scripts/net/world_net.gd"
|
WorldNet="*res://scripts/net/world_net.gd"
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ extends Node
|
|||||||
|
|
||||||
signal notify(text: String) ## short HUD toast ("+4 Wood", "Need an axe")
|
signal notify(text: String) ## short HUD toast ("+4 Wood", "Need an axe")
|
||||||
signal item_gained(id: String, n: int)
|
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 STATION_RANGE := 6.0
|
||||||
const REACH := 3.2
|
const REACH := 3.2
|
||||||
@@ -207,6 +218,7 @@ func craft(player: Node, recipe_id: String) -> bool:
|
|||||||
for id in r.inputs:
|
for id in r.inputs:
|
||||||
inv.remove(id, int(r.inputs[id]))
|
inv.remove(id, int(r.inputs[id]))
|
||||||
_give(player, r.output, r.count)
|
_give(player, r.output, r.count)
|
||||||
|
crafted.emit(player, recipe_id, String(r.output), int(r.count))
|
||||||
return true
|
return true
|
||||||
|
|
||||||
func pickup(player: Node3D, pickup_node: Node3D) -> bool:
|
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:
|
for id in drops:
|
||||||
_give(player, id, int(drops[id]))
|
_give(player, id, int(drops[id]))
|
||||||
use_held(player)
|
use_held(player)
|
||||||
|
harvested.emit(player, node, bool(node.depleted))
|
||||||
return true
|
return true
|
||||||
|
|
||||||
func can_place(player: Node3D, slot: int) -> bool:
|
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")
|
notify.emit("Can't place that here")
|
||||||
return false
|
return false
|
||||||
player.inventory.take_from(slot, 1)
|
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
|
return true
|
||||||
|
|
||||||
## Takes a placed piece back into your inventory. Building pieces that lose
|
## 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)
|
notify.emit(why)
|
||||||
return false
|
return false
|
||||||
world.sleep(player)
|
world.sleep(player)
|
||||||
|
slept.emit(player)
|
||||||
return true
|
return true
|
||||||
|
|
||||||
# --- hunting and combat ---------------------------------------------------------
|
# --- hunting and combat ---------------------------------------------------------
|
||||||
@@ -420,6 +435,7 @@ func melee(player: Node3D, target: Node3D) -> bool:
|
|||||||
use_held(player)
|
use_held(player)
|
||||||
if target.is_dead():
|
if target.is_dead():
|
||||||
notify.emit("Killed a %s" % target.display_name().to_lower())
|
notify.emit("Killed a %s" % target.display_name().to_lower())
|
||||||
|
killed.emit(player, String(target.kind))
|
||||||
return true
|
return true
|
||||||
|
|
||||||
## A melee blow at another player (online, with PvP on). Online only.
|
## 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)
|
target.take_hit(damage, shooter.global_position if shooter != null else at, embed_id)
|
||||||
if was_alive and target.is_dead():
|
if was_alive and target.is_dead():
|
||||||
tell(shooter, "Killed a %s" % target.display_name().to_lower())
|
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`.
|
## Throws the spear in `slot` from `origin` along `dir`.
|
||||||
func throw_spear(player: Node3D, slot: int, origin: Vector3, dir: Vector3, world: Node3D) -> bool:
|
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
|
return false
|
||||||
var drops: Dictionary = carcass.butcher_drops()
|
var drops: Dictionary = carcass.butcher_drops()
|
||||||
carcass.remove_from_group("carcass")
|
carcass.remove_from_group("carcass")
|
||||||
|
butchered.emit(player, String(carcass.kind))
|
||||||
for id in drops:
|
for id in drops:
|
||||||
var got := _give(player, id, int(drops[id]))
|
var got := _give(player, id, int(drops[id]))
|
||||||
if got < 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:
|
if _give(player, caught, 1) == 0:
|
||||||
world.drop_item(caught, 1, Transform3D(Basis(), player.global_position + Vector3(0, 0.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())
|
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
|
return true
|
||||||
|
|
||||||
## Puts on the cloak in `slot` (swapping out any cloak you were wearing).
|
## 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")
|
notify.emit("You're full")
|
||||||
return false
|
return false
|
||||||
player.inventory.take_from(slot, 1)
|
player.inventory.take_from(slot, 1)
|
||||||
|
var eaten := String(s.id)
|
||||||
player.survival.eat(food)
|
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.
|
# 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)):
|
if food_rng.randf() < float(food.get("risk", 0.0)):
|
||||||
player.survival.poison()
|
player.survival.poison()
|
||||||
|
|||||||
283
scripts/core/guide.gd
Normal file
283
scripts/core/guide.gd
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
extends Node
|
||||||
|
## Autoload "Guide": the tutorial (docs/TUTORIAL_DESIGN.md §3).
|
||||||
|
##
|
||||||
|
## Runs the steps in data/tutorial.json in order. Each step has a goal made
|
||||||
|
## of real game events (Actions' signals: items gained, things crafted,
|
||||||
|
## trees felled...) plus walking; the Guide counts them from the moment the
|
||||||
|
## tutorial starts, so a step you've already done ticks as soon as it comes
|
||||||
|
## up. Nothing is forced and nothing is on a timer.
|
||||||
|
##
|
||||||
|
## Goals:
|
||||||
|
## {"walk": metres} walked that far in all
|
||||||
|
## {"gained": id, "count": n} picked up / given that many
|
||||||
|
## {"crafted": recipe_id} (and "count")
|
||||||
|
## {"felled": "tree" | "rock"} chopped down / broken
|
||||||
|
## {"placed": item_id}, {"ate": item_id or "any"}, {"killed": kind},
|
||||||
|
## {"butchered": kind}, {"caught": "any"}, {"slept": true}
|
||||||
|
## {"continue": true} the player pressed Continue
|
||||||
|
## {"all": [goal, goal, ...]} every one of them
|
||||||
|
## Markers: {"nearest": "pickup:<id>" | "tree" | "rock"}, optionally
|
||||||
|
## {"unless_have": {id: n}} to hide it once you've got enough.
|
||||||
|
##
|
||||||
|
## Single-player only (the tutorial is your own world). The panel and the
|
||||||
|
## in-world marker are ui/guide.gd. Progress is saved with the world.
|
||||||
|
|
||||||
|
signal started
|
||||||
|
signal step_started(index: int, step: Dictionary)
|
||||||
|
signal step_done(index: int, step: Dictionary)
|
||||||
|
signal progressed ## a count went up (the panel redraws)
|
||||||
|
signal finished
|
||||||
|
|
||||||
|
const DATA := "res://data/tutorial.json"
|
||||||
|
|
||||||
|
var active := false
|
||||||
|
var steps: Array = []
|
||||||
|
var chapters: Array = []
|
||||||
|
var index := 0
|
||||||
|
var done: Array = [] ## step ids, in the order they ticked
|
||||||
|
var counters: Dictionary = {} ## "gained:stick" -> 3, "walk" -> 12.5, ...
|
||||||
|
var world: Node3D
|
||||||
|
var _last_pos := Vector3.INF
|
||||||
|
var _marker_t := 0.0
|
||||||
|
var _marker := Vector3.INF
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_load_data()
|
||||||
|
var A: Node = get_node("/root/Actions")
|
||||||
|
A.item_gained.connect(_on_gained)
|
||||||
|
A.crafted.connect(_on_crafted)
|
||||||
|
A.harvested.connect(_on_harvested)
|
||||||
|
A.placed.connect(_on_placed)
|
||||||
|
A.ate.connect(_on_ate)
|
||||||
|
A.killed.connect(_on_killed)
|
||||||
|
A.butchered.connect(_on_butchered)
|
||||||
|
A.caught.connect(_on_caught)
|
||||||
|
A.slept.connect(_on_slept)
|
||||||
|
|
||||||
|
func _on_gained(id: String, n: int) -> void:
|
||||||
|
_count("gained:" + id, n)
|
||||||
|
|
||||||
|
func _on_crafted(_p: Node, rid: String, _out: String, _n: int) -> void:
|
||||||
|
_count("crafted:" + rid, 1)
|
||||||
|
|
||||||
|
func _on_harvested(_p: Node, node: Node, felled: bool) -> void:
|
||||||
|
if felled:
|
||||||
|
_count("felled:" + String(node.kind), 1)
|
||||||
|
|
||||||
|
func _on_placed(_p: Node, id: String, _piece: Node) -> void:
|
||||||
|
_count("placed:" + id, 1)
|
||||||
|
|
||||||
|
func _on_ate(_p: Node, id: String) -> void:
|
||||||
|
_count("ate:" + id, 1)
|
||||||
|
_count("ate:any", 1)
|
||||||
|
|
||||||
|
func _on_killed(_p: Node, kind: String) -> void:
|
||||||
|
_count("killed:" + kind, 1)
|
||||||
|
|
||||||
|
func _on_butchered(_p: Node, kind: String) -> void:
|
||||||
|
_count("butchered:" + kind, 1)
|
||||||
|
|
||||||
|
func _on_caught(_p: Node, fish: String) -> void:
|
||||||
|
_count("caught:" + fish, 1)
|
||||||
|
_count("caught:any", 1)
|
||||||
|
|
||||||
|
func _on_slept(_p: Node) -> void:
|
||||||
|
_count("slept:true", 1)
|
||||||
|
|
||||||
|
func _load_data() -> void:
|
||||||
|
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(DATA)) if FileAccess.file_exists(DATA) else null
|
||||||
|
if d is Dictionary:
|
||||||
|
steps = d.get("steps", [])
|
||||||
|
chapters = d.get("chapters", [])
|
||||||
|
guide_name = String(d.get("guide", {}).get("name", ""))
|
||||||
|
|
||||||
|
## Who's talking you through it (data/tutorial.json "guide": Old Tam, the
|
||||||
|
## island's hermit).
|
||||||
|
var guide_name := ""
|
||||||
|
|
||||||
|
# --- running it -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
func start(w: Node3D, from_saved: Dictionary = {}) -> void:
|
||||||
|
world = w
|
||||||
|
active = true
|
||||||
|
index = 0
|
||||||
|
done = []
|
||||||
|
counters = {}
|
||||||
|
_last_pos = Vector3.INF
|
||||||
|
if not from_saved.is_empty():
|
||||||
|
load_data(from_saved)
|
||||||
|
started.emit()
|
||||||
|
_enter()
|
||||||
|
|
||||||
|
func stop() -> void:
|
||||||
|
active = false
|
||||||
|
world = null
|
||||||
|
|
||||||
|
func current() -> Dictionary:
|
||||||
|
return steps[index] if active and index < steps.size() else {}
|
||||||
|
|
||||||
|
func is_finished() -> bool:
|
||||||
|
return active and index >= steps.size()
|
||||||
|
|
||||||
|
func chapter_title(step: Dictionary) -> String:
|
||||||
|
for c in chapters:
|
||||||
|
if c.id == step.get("chapter", ""):
|
||||||
|
return String(c.title)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
func _enter() -> void:
|
||||||
|
if index >= steps.size():
|
||||||
|
finished.emit()
|
||||||
|
return
|
||||||
|
step_started.emit(index, steps[index])
|
||||||
|
_marker_t = 0.0
|
||||||
|
_check() # (done early? it ticks straight away)
|
||||||
|
|
||||||
|
## The player read a "Continue" step.
|
||||||
|
func continue_step() -> void:
|
||||||
|
_count("continue:true", 1)
|
||||||
|
|
||||||
|
## Skips the current step (pause menu, T2).
|
||||||
|
func skip() -> void:
|
||||||
|
if not current().is_empty():
|
||||||
|
_advance()
|
||||||
|
|
||||||
|
func _count(key: String, n: float) -> void:
|
||||||
|
counters[key] = float(counters.get(key, 0.0)) + n
|
||||||
|
if active:
|
||||||
|
progressed.emit()
|
||||||
|
_check()
|
||||||
|
|
||||||
|
func _check() -> void:
|
||||||
|
var s := current()
|
||||||
|
if s.is_empty():
|
||||||
|
return
|
||||||
|
if _met(s.goal):
|
||||||
|
_advance()
|
||||||
|
|
||||||
|
func _advance() -> void:
|
||||||
|
var s := current()
|
||||||
|
done.append(String(s.id))
|
||||||
|
step_done.emit(index, s)
|
||||||
|
index += 1
|
||||||
|
# A "continue" only counts for the step it was pressed on.
|
||||||
|
counters.erase("continue:true")
|
||||||
|
_enter()
|
||||||
|
|
||||||
|
# --- goals -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## [[label, have, need], ...] for each part of `goal`.
|
||||||
|
func parts(goal: Dictionary) -> Array:
|
||||||
|
if goal.has("all"):
|
||||||
|
var out := []
|
||||||
|
for g in goal.all:
|
||||||
|
out.append_array(parts(g))
|
||||||
|
return out
|
||||||
|
var need := float(goal.get("count", 1))
|
||||||
|
for k in ["gained", "crafted", "felled", "placed", "ate", "killed", "butchered", "caught", "slept", "continue"]:
|
||||||
|
if goal.has(k):
|
||||||
|
var key := "%s:%s" % [k, String(goal[k]).to_lower() if goal[k] is bool else String(goal[k])]
|
||||||
|
return [[_label(k, goal), float(counters.get(key, 0.0)), need]]
|
||||||
|
if goal.has("walk"):
|
||||||
|
return [["Walk", floorf(float(counters.get("walk", 0.0))), float(goal.walk)]]
|
||||||
|
return []
|
||||||
|
|
||||||
|
func _label(kind: String, goal: Dictionary) -> String:
|
||||||
|
if goal.has("label"):
|
||||||
|
return String(goal.label)
|
||||||
|
match kind:
|
||||||
|
"gained": return _plural(ItemDB.item_name(String(goal.gained)), int(goal.get("count", 1)))
|
||||||
|
"crafted":
|
||||||
|
var r: Dictionary = ItemDB.recipes.get(String(goal.crafted), {})
|
||||||
|
return ItemDB.item_name(String(r.get("output", goal.crafted)))
|
||||||
|
"felled": return "Tree" if goal.felled == "tree" else "Boulder"
|
||||||
|
"continue": return ""
|
||||||
|
return kind.capitalize()
|
||||||
|
|
||||||
|
static func _plural(name: String, n: int) -> String:
|
||||||
|
if n <= 1 or name.ends_with("s") or name.to_lower() in ["fiber", "fibre", "wood", "stone", "flint", "clay", "salt"]:
|
||||||
|
return name
|
||||||
|
return name + "s"
|
||||||
|
|
||||||
|
func _met(goal: Dictionary) -> bool:
|
||||||
|
for p in parts(goal):
|
||||||
|
if float(p[1]) < float(p[2]):
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
## "Sticks 2/3 · Stone 1/3" for the current step (empty for one-off goals).
|
||||||
|
func progress_text() -> String:
|
||||||
|
var s := current()
|
||||||
|
if s.is_empty():
|
||||||
|
return ""
|
||||||
|
var bits := []
|
||||||
|
for p in parts(s.goal):
|
||||||
|
if float(p[2]) > 1.0:
|
||||||
|
bits.append("%s %d/%d" % [p[0], mini(int(p[1]), int(p[2])), int(p[2])])
|
||||||
|
elif not String(p[0]).is_empty():
|
||||||
|
bits.append(("✓ " if float(p[1]) >= 1.0 else "") + String(p[0]))
|
||||||
|
return " · ".join(PackedStringArray(bits))
|
||||||
|
|
||||||
|
# --- walking and the marker ------------------------------------------------------------
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if not active or world == null or not is_instance_valid(world) or world.player == null:
|
||||||
|
return
|
||||||
|
var p: Vector3 = world.player.global_position
|
||||||
|
if _last_pos != Vector3.INF:
|
||||||
|
var d := Vector2(p.x - _last_pos.x, p.z - _last_pos.z).length()
|
||||||
|
if d > 0.01 and d < 5.0: # (not a teleport or a respawn)
|
||||||
|
_count("walk", d)
|
||||||
|
_last_pos = p
|
||||||
|
_marker_t -= delta
|
||||||
|
if _marker_t <= 0.0:
|
||||||
|
_marker_t = 0.5
|
||||||
|
_marker = find_marker()
|
||||||
|
|
||||||
|
## Called by tests (no frames): moves the walk counter on by `metres`.
|
||||||
|
func walked(metres: float) -> void:
|
||||||
|
_count("walk", metres)
|
||||||
|
|
||||||
|
func marker_position() -> Vector3:
|
||||||
|
return _marker
|
||||||
|
|
||||||
|
## Where the current step's marker goes: the nearest thing it asks for, or
|
||||||
|
## INF (no marker, or you've got what it was for).
|
||||||
|
func find_marker() -> Vector3:
|
||||||
|
var s := current()
|
||||||
|
if s.is_empty() or not s.has("marker") or world == null:
|
||||||
|
return Vector3.INF
|
||||||
|
var m: Dictionary = s.marker
|
||||||
|
for id in m.get("unless_have", {}):
|
||||||
|
if world.player.inventory.count(id) >= int(m.unless_have[id]):
|
||||||
|
return Vector3.INF
|
||||||
|
var what := String(m.get("nearest", ""))
|
||||||
|
var from: Vector3 = world.player.global_position
|
||||||
|
var best := Vector3.INF
|
||||||
|
var best_d := INF
|
||||||
|
var cands: Array = []
|
||||||
|
if what.begins_with("pickup:"):
|
||||||
|
var id := what.substr(7)
|
||||||
|
for pk in world.props.pickups.values():
|
||||||
|
if is_instance_valid(pk) and not pk.taken and pk.item_id == id:
|
||||||
|
cands.append(pk)
|
||||||
|
elif what in ["tree", "rock"]:
|
||||||
|
for n in world.props.resource_nodes.values():
|
||||||
|
if is_instance_valid(n) and n.kind == what and not n.depleted:
|
||||||
|
cands.append(n)
|
||||||
|
for c in cands:
|
||||||
|
var d: float = (c as Node3D).global_position.distance_to(from)
|
||||||
|
if d < best_d:
|
||||||
|
best_d = d
|
||||||
|
best = (c as Node3D).global_position
|
||||||
|
return best
|
||||||
|
|
||||||
|
# --- saving --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func save_data() -> Dictionary:
|
||||||
|
return {"index": index, "done": done.duplicate(), "counters": counters.duplicate()} if active else {}
|
||||||
|
|
||||||
|
func load_data(d: Dictionary) -> void:
|
||||||
|
index = clampi(int(d.get("index", 0)), 0, steps.size())
|
||||||
|
done = (d.get("done", []) as Array).duplicate()
|
||||||
|
counters = (d.get("counters", {}) as Dictionary).duplicate()
|
||||||
177
scripts/ui/guide.gd
Normal file
177
scripts/ui/guide.gd
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
extends Control
|
||||||
|
## The tutorial's guide panel and marker (docs/TUTORIAL_DESIGN.md §1): what to
|
||||||
|
## do now, top right under the clock, with a progress count; a tick when a
|
||||||
|
## step's done; a pale glowing post where to go, and an arrow with the
|
||||||
|
## distance when it's off-screen. H hides the panel. Drawn from the Guide
|
||||||
|
## autoload; hidden when no tutorial is running.
|
||||||
|
|
||||||
|
const UITheme = preload("res://scripts/ui/theme.gd")
|
||||||
|
|
||||||
|
var player: Node3D
|
||||||
|
var _box: PanelContainer
|
||||||
|
var _chapter: Label
|
||||||
|
var _title: Label
|
||||||
|
var _text: Label
|
||||||
|
var _progress: Label
|
||||||
|
var _tick: Label
|
||||||
|
var _arrow: Label
|
||||||
|
var _marker: Node3D
|
||||||
|
var _hidden := false
|
||||||
|
|
||||||
|
func setup(p: Node3D) -> void:
|
||||||
|
player = p
|
||||||
|
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_box = UITheme.panel()
|
||||||
|
_box.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
|
||||||
|
_box.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||||
|
_box.offset_right = -22
|
||||||
|
_box.offset_top = 48
|
||||||
|
_box.custom_minimum_size = Vector2(340, 0)
|
||||||
|
_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
add_child(_box)
|
||||||
|
var v := VBoxContainer.new()
|
||||||
|
v.add_theme_constant_override("separation", 4)
|
||||||
|
v.custom_minimum_size = Vector2(320, 0)
|
||||||
|
_box.add_child(v)
|
||||||
|
_chapter = UITheme.label("", 13, UITheme.TEXT_DIM)
|
||||||
|
v.add_child(_chapter)
|
||||||
|
_title = UITheme.label("", 19)
|
||||||
|
v.add_child(_title)
|
||||||
|
_text = UITheme.label("", 15, UITheme.TEXT_DIM)
|
||||||
|
_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
_text.custom_minimum_size = Vector2(320, 0)
|
||||||
|
v.add_child(_text)
|
||||||
|
_progress = UITheme.label("", 15, Color(0.95, 0.82, 0.5))
|
||||||
|
v.add_child(_progress)
|
||||||
|
_tick = UITheme.label("", 22, Color(0.55, 0.9, 0.45))
|
||||||
|
_tick.set_anchors_and_offsets_preset(Control.PRESET_CENTER_TOP)
|
||||||
|
_tick.offset_top = 90
|
||||||
|
_tick.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_tick.modulate.a = 0.0
|
||||||
|
add_child(_tick)
|
||||||
|
_arrow = UITheme.label("", 20, Color(0.75, 0.95, 1.0))
|
||||||
|
_arrow.visible = false
|
||||||
|
add_child(_arrow)
|
||||||
|
Guide.step_started.connect(func(_i, _s): _redraw())
|
||||||
|
Guide.progressed.connect(_redraw)
|
||||||
|
Guide.step_done.connect(_on_done)
|
||||||
|
Guide.started.connect(_redraw)
|
||||||
|
Guide.finished.connect(_on_finished)
|
||||||
|
_redraw()
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if Guide.active and event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_H:
|
||||||
|
_hidden = not _hidden
|
||||||
|
_redraw()
|
||||||
|
|
||||||
|
func _redraw() -> void:
|
||||||
|
var s: Dictionary = Guide.current()
|
||||||
|
visible = Guide.active
|
||||||
|
_box.visible = Guide.active and not _hidden and not s.is_empty()
|
||||||
|
if s.is_empty():
|
||||||
|
return
|
||||||
|
_chapter.text = "%s · step %d of %d (H hides)" % [Guide.chapter_title(s), Guide.index + 1, Guide.steps.size()]
|
||||||
|
_title.text = String(s.title)
|
||||||
|
# The hermit's words, in quotes, with his name.
|
||||||
|
_text.text = ("%s: \"%s\"" % [Guide.guide_name, String(s.text)]) if not Guide.guide_name.is_empty() else String(s.text)
|
||||||
|
_progress.text = Guide.progress_text()
|
||||||
|
|
||||||
|
func _on_done(_i: int, s: Dictionary) -> void:
|
||||||
|
_tick.text = "✓ " + String(s.title)
|
||||||
|
var tw := create_tween()
|
||||||
|
_tick.modulate.a = 1.0
|
||||||
|
tw.tween_interval(1.4)
|
||||||
|
tw.tween_property(_tick, "modulate:a", 0.0, 0.6)
|
||||||
|
|
||||||
|
func _on_finished() -> void:
|
||||||
|
_box.visible = false
|
||||||
|
_tick.text = "✓ Chapter done. More of the tutorial is on its way."
|
||||||
|
_tick.modulate.a = 1.0
|
||||||
|
var tw := create_tween()
|
||||||
|
tw.tween_interval(3.0)
|
||||||
|
tw.tween_property(_tick, "modulate:a", 0.0, 1.0)
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
_update_marker()
|
||||||
|
|
||||||
|
## The glowing post at the target, and the arrow at the screen edge.
|
||||||
|
func _update_marker() -> void:
|
||||||
|
var at: Vector3 = Guide.marker_position() if Guide.active else Vector3.INF
|
||||||
|
if _marker != null and (not is_instance_valid(_marker) or _marker.get_parent() != Guide.world):
|
||||||
|
if is_instance_valid(_marker):
|
||||||
|
_marker.queue_free()
|
||||||
|
_marker = null # (its world went: a reload, or back to the title)
|
||||||
|
if _marker == null and Guide.active and Guide.world != null:
|
||||||
|
_marker = make_marker()
|
||||||
|
Guide.world.add_child(_marker)
|
||||||
|
if _marker == null:
|
||||||
|
_arrow.visible = false
|
||||||
|
return
|
||||||
|
_marker.visible = at != Vector3.INF
|
||||||
|
if at == Vector3.INF:
|
||||||
|
_arrow.visible = false
|
||||||
|
return
|
||||||
|
_marker.global_position = at
|
||||||
|
var cam := get_viewport().get_camera_3d()
|
||||||
|
if cam == null or _hidden:
|
||||||
|
_arrow.visible = false
|
||||||
|
return
|
||||||
|
var vis := not cam.is_position_behind(at)
|
||||||
|
var sp := cam.unproject_position(at + Vector3(0, 1.0, 0))
|
||||||
|
var r := get_viewport_rect()
|
||||||
|
if vis and r.grow(-40).has_point(sp):
|
||||||
|
_arrow.visible = false
|
||||||
|
return
|
||||||
|
# Off-screen: an arrow at the edge, pointing the way, with the distance.
|
||||||
|
var c := r.size * 0.5
|
||||||
|
var dir := (sp - c) * (-1.0 if not vis else 1.0)
|
||||||
|
if dir.length() < 1.0:
|
||||||
|
dir = Vector2(0, -1)
|
||||||
|
dir = dir.normalized()
|
||||||
|
var edge := c + dir * (minf(c.x, c.y) - 70.0)
|
||||||
|
edge = edge.clamp(Vector2(40, 40), r.size - Vector2(140, 60))
|
||||||
|
var arrows := ["→", "↘", "↓", "↙", "←", "↖", "↑", "↗"]
|
||||||
|
var k := wrapi(roundi(atan2(dir.y, dir.x) / (PI / 4.0)), 0, 8)
|
||||||
|
var who: Node3D = Guide.world.player if Guide.world != null else player
|
||||||
|
_arrow.text = "%s %d m" % [arrows[k], roundi(who.global_position.distance_to(at))]
|
||||||
|
_arrow.position = edge
|
||||||
|
_arrow.visible = true
|
||||||
|
|
||||||
|
## A pale, glowing post with a ring at its foot.
|
||||||
|
static func make_marker() -> Node3D:
|
||||||
|
var root := Node3D.new()
|
||||||
|
root.name = "GuideMarker"
|
||||||
|
var m := StandardMaterial3D.new()
|
||||||
|
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
|
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||||
|
m.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
|
||||||
|
m.albedo_color = Color(0.55, 0.85, 1.0, 0.35)
|
||||||
|
m.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||||
|
var beam := MeshInstance3D.new()
|
||||||
|
var cyl := CylinderMesh.new()
|
||||||
|
cyl.top_radius = 0.06
|
||||||
|
cyl.bottom_radius = 0.14
|
||||||
|
cyl.height = 5.0
|
||||||
|
cyl.material = m
|
||||||
|
beam.mesh = cyl
|
||||||
|
beam.position = Vector3(0, 2.6, 0)
|
||||||
|
beam.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||||
|
root.add_child(beam)
|
||||||
|
var ring := MeshInstance3D.new()
|
||||||
|
var torus := TorusMesh.new()
|
||||||
|
torus.inner_radius = 0.55
|
||||||
|
torus.outer_radius = 0.7
|
||||||
|
torus.material = m
|
||||||
|
ring.mesh = torus
|
||||||
|
ring.position = Vector3(0, 0.05, 0)
|
||||||
|
ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||||
|
root.add_child(ring)
|
||||||
|
if DisplayServer.get_name() != "headless":
|
||||||
|
var l := OmniLight3D.new()
|
||||||
|
l.light_color = Color(0.55, 0.85, 1.0)
|
||||||
|
l.light_energy = 0.6
|
||||||
|
l.omni_range = 4.0
|
||||||
|
l.position = Vector3(0, 1.0, 0)
|
||||||
|
root.add_child(l)
|
||||||
|
return root
|
||||||
@@ -174,6 +174,11 @@ func _build_stats(root: Control) -> void:
|
|||||||
_clock.offset_right = -22
|
_clock.offset_right = -22
|
||||||
_clock.offset_top = 14
|
_clock.offset_top = 14
|
||||||
root.add_child(_clock)
|
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:
|
func _process(_d: float) -> void:
|
||||||
_fade_chat()
|
_fade_chat()
|
||||||
|
|||||||
@@ -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 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 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 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 net_sync: Node ## net_sync.gd when playing on a server, else null
|
||||||
var has_bed_spawn := false
|
var has_bed_spawn := false
|
||||||
var bed_spawn := Vector3.ZERO ## position of the bed you respawn at
|
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)
|
wolves.setup(self, den)
|
||||||
if not "--no-creatures" in OS.get_cmdline_user_args():
|
if not "--no-creatures" in OS.get_cmdline_user_args():
|
||||||
_setup_bear()
|
_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()
|
_maybe_capture()
|
||||||
|
|
||||||
## The island's bear and its patch (bear_range.gd), and the crows and foxes
|
## 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:
|
func _exit_tree() -> void:
|
||||||
if server_mode and Saves.has_world() and _session_time > 0.0:
|
if server_mode and Saves.has_world() and _session_time > 0.0:
|
||||||
save_now()
|
save_now()
|
||||||
|
if Guide.world == self:
|
||||||
|
Guide.stop()
|
||||||
|
|
||||||
func _notification(what: int) -> void:
|
func _notification(what: int) -> void:
|
||||||
if what != NOTIFICATION_WM_CLOSE_REQUEST or menu_mode:
|
if what != NOTIFICATION_WM_CLOSE_REQUEST or menu_mode:
|
||||||
@@ -601,6 +608,7 @@ func collect_save() -> Dictionary:
|
|||||||
},
|
},
|
||||||
"dropped": dropped,
|
"dropped": dropped,
|
||||||
"time": {"day": day_night.day, "time": day_night.time},
|
"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]},
|
"spawn": {"bed": has_bed_spawn, "pos": [bed_spawn.x, bed_spawn.y, bed_spawn.z]},
|
||||||
"forage_regrow": regrow,
|
"forage_regrow": regrow,
|
||||||
"regrow": props.regrow.duplicate(),
|
"regrow": props.regrow.duplicate(),
|
||||||
@@ -750,6 +758,7 @@ func _apply_save(s: Dictionary, with_player: bool) -> void:
|
|||||||
props.pickups[uid].collected()
|
props.pickups[uid].collected()
|
||||||
# Regrowth timers (older saves have none: everything gone starts a fresh timer).
|
# Regrowth timers (older saves have none: everything gone starts a fresh timer).
|
||||||
props.restore_regrow(s.get("regrow", {}))
|
props.restore_regrow(s.get("regrow", {}))
|
||||||
|
_saved_tutorial = s.get("tutorial", {}) if with_player else {}
|
||||||
if barrow != null:
|
if barrow != null:
|
||||||
barrow.load_data(s.get("barrow", {}))
|
barrow.load_data(s.get("barrow", {}))
|
||||||
if stones != null:
|
if stones != null:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
|
|||||||
echo "Importing the project first (fresh copy)..."
|
echo "Importing the project first (fresh copy)..."
|
||||||
godot --headless --path . --import >/dev/null 2>&1
|
godot --headless --path . --import >/dev/null 2>&1
|
||||||
fi
|
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=""
|
extra=""
|
||||||
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
|
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
|
||||||
# A suite that waits on a world that failed to load would hang forever,
|
# A suite that waits on a world that failed to load would hang forever,
|
||||||
|
|||||||
223
tests/test_tutorial.gd
Normal file
223
tests/test_tutorial.gd
Normal file
@@ -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")
|
||||||
Reference in New Issue
Block a user