417 lines
14 KiB
GDScript
417 lines
14 KiB
GDScript
extends Node
|
|
## Autoload "Guide": the tutorial (docs/TUTORIAL_DESIGN.md §3).
|
|
##
|
|
## Runs the steps in data/tutorial.json in order. Each step has a goal made
|
|
## of real game events (Actions' signals: items gained, things crafted,
|
|
## trees felled...) plus walking; the Guide counts them from the moment the
|
|
## tutorial starts, so a step you've already done ticks as soon as it comes
|
|
## up. Nothing is forced and nothing is on a timer.
|
|
##
|
|
## Goals:
|
|
## {"walk": metres} walked that far in all
|
|
## {"gained": id, "count": n} picked up / given that many
|
|
## {"crafted": recipe_id} (and "count")
|
|
## {"felled": "tree" | "rock"} chopped down / broken
|
|
## {"placed": item_id}, {"ate": item_id or "any"}, {"killed": kind},
|
|
## {"butchered": kind}, {"caught": "any"}, {"slept": true}
|
|
## {"continue": true} the player pressed Enter ("Continue")
|
|
## {"shelter": true} standing under a roof with walls round you
|
|
## {"bed_spawn": true} you've set your spawn at a bed
|
|
## {"demo_wolf": true} the tutorial's wolf has been and gone
|
|
## {"all": [goal, goal, ...]} every one of them
|
|
## {"any": [goal, goal, ...]} one of them
|
|
## Markers: {"nearest": "pickup:<id>" | "tree" | "rock" | "bush" | "lake" |
|
|
## "creature:<kind>" | "carcass" | "piece:<item id>"}, optionally
|
|
## {"unless_have": {id: n}} to hide it once you've got enough.
|
|
## Set-up, when a step comes up: {"time": hours} (the clock jumps on),
|
|
## {"spawn": {"kind", "count", "distance"}} (calm animals round you),
|
|
## {"demo_wolf": true} (a wolf that circles and leaves: tutorial_wolf.gd).
|
|
##
|
|
## Single-player only (the tutorial is your own world). The panel and the
|
|
## in-world marker are ui/guide.gd. Progress is saved with the world.
|
|
|
|
signal started
|
|
signal step_started(index: int, step: Dictionary)
|
|
signal step_done(index: int, step: Dictionary)
|
|
signal progressed ## a count went up (the panel redraws)
|
|
signal finished
|
|
|
|
const DATA := "res://data/tutorial.json"
|
|
|
|
var active := false
|
|
var steps: Array = []
|
|
var chapters: Array = []
|
|
var index := 0
|
|
var done: Array = [] ## step ids, in the order they ticked
|
|
var counters: Dictionary = {} ## "gained:stick" -> 3, "walk" -> 12.5, ...
|
|
var world: Node3D
|
|
var _last_pos := Vector3.INF
|
|
var _marker_t := 0.0
|
|
var _marker := Vector3.INF
|
|
|
|
func _ready() -> void:
|
|
_load_data()
|
|
var A: Node = get_node("/root/Actions")
|
|
A.item_gained.connect(_on_gained)
|
|
A.crafted.connect(_on_crafted)
|
|
A.harvested.connect(_on_harvested)
|
|
A.placed.connect(_on_placed)
|
|
A.ate.connect(_on_ate)
|
|
A.killed.connect(_on_killed)
|
|
A.butchered.connect(_on_butchered)
|
|
A.caught.connect(_on_caught)
|
|
A.slept.connect(_on_slept)
|
|
|
|
func _on_gained(id: String, n: int) -> void:
|
|
_count("gained:" + id, n)
|
|
|
|
func _on_crafted(_p: Node, rid: String, _out: String, _n: int) -> void:
|
|
_count("crafted:" + rid, 1)
|
|
|
|
func _on_harvested(_p: Node, node: Node, felled: bool) -> void:
|
|
if felled:
|
|
_count("felled:" + String(node.kind), 1)
|
|
|
|
func _on_placed(_p: Node, id: String, _piece: Node) -> void:
|
|
_count("placed:" + id, 1)
|
|
|
|
func _on_ate(_p: Node, id: String) -> void:
|
|
_count("ate:" + id, 1)
|
|
_count("ate:any", 1)
|
|
|
|
func _on_killed(_p: Node, kind: String) -> void:
|
|
_count("killed:" + kind, 1)
|
|
|
|
func _on_butchered(_p: Node, kind: String) -> void:
|
|
_count("butchered:" + kind, 1)
|
|
|
|
func _on_caught(_p: Node, fish: String) -> void:
|
|
_count("caught:" + fish, 1)
|
|
_count("caught:any", 1)
|
|
|
|
func _on_slept(_p: Node) -> void:
|
|
_count("slept:true", 1)
|
|
|
|
func _load_data() -> void:
|
|
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(DATA)) if FileAccess.file_exists(DATA) else null
|
|
if d is Dictionary:
|
|
steps = d.get("steps", [])
|
|
chapters = d.get("chapters", [])
|
|
guide_name = String(d.get("guide", {}).get("name", ""))
|
|
finish = d.get("finish", {})
|
|
|
|
## Who's talking you through it (data/tutorial.json "guide": Old Tam, the
|
|
## island's hermit).
|
|
var guide_name := ""
|
|
var finish: Dictionary = {} ## the card at the end: title and text
|
|
|
|
# --- running it -----------------------------------------------------------------------
|
|
|
|
func start(w: Node3D, from_saved: Dictionary = {}) -> void:
|
|
world = w
|
|
active = true
|
|
index = 0
|
|
done = []
|
|
counters = {}
|
|
_last_pos = Vector3.INF
|
|
if not from_saved.is_empty():
|
|
load_data(from_saved)
|
|
started.emit()
|
|
_enter()
|
|
|
|
func stop() -> void:
|
|
_clear_outline()
|
|
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
|
|
_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")
|
|
const HutOutline = preload("res://scripts/ui/hut_outline.gd")
|
|
var demo_wolf: Node3D = null
|
|
var outline: Node3D = null ## the hut outline, while the hut step is on
|
|
|
|
func _clear_outline() -> void:
|
|
if outline != null and is_instance_valid(outline):
|
|
outline.queue_free()
|
|
outline = null
|
|
if world != null and is_instance_valid(world):
|
|
world.build.outline = null
|
|
|
|
## A step's set-up: the clock, some animals, the demo wolf.
|
|
func _setup(su: Dictionary) -> void:
|
|
if su.is_empty() or world == null or not is_instance_valid(world):
|
|
return
|
|
if su.has("time"):
|
|
var want: float = float(su.time) / 24.0
|
|
if world.day_night.time < want:
|
|
world.day_night.time = want
|
|
world.day_night.apply(true)
|
|
if su.has("spawn"):
|
|
var sp: Dictionary = su.spawn
|
|
var p: Vector3 = world.player.global_position
|
|
var rng := RandomNumberGenerator.new()
|
|
rng.seed = int(p.x * 13.0 + p.z * 7.0)
|
|
for i in range(int(sp.get("count", 1))):
|
|
for _try in range(12):
|
|
var a := rng.randf() * TAU
|
|
var at := p + Vector3(cos(a), 0, sin(a)) * float(sp.get("distance", 12.0))
|
|
if world.terrain.inside_boundary(at.x, at.z, 4.0) and world.terrain.sample_height(at.x, at.z) > world.terrain.water_level + 0.3:
|
|
world.creatures.spawn(String(sp.kind), at)
|
|
break
|
|
if String(su.get("outline", "")) == "hut":
|
|
_clear_outline()
|
|
# A good spot a few metres from your fire (or from you).
|
|
# Search out from the fire in small rings, starting far enough out
|
|
# that the hut (3 m half-width) leaves the fire 2 m clear of its walls.
|
|
var near: Vector3 = world.player.global_position
|
|
var step := 4.0
|
|
var min_dist := 0.0
|
|
for pc in world.all_pieces():
|
|
if pc.item_id == "campfire" and pc.global_position.distance_to(near) < 30.0:
|
|
near = pc.global_position
|
|
step = 1.5
|
|
min_dist = 5.0
|
|
var site: Vector3 = world.build.debug_find_site(near, 3.0, step, min_dist)
|
|
var yaw: float = world.player.rotation.y
|
|
var b := Basis(Vector3.UP, yaw)
|
|
var first: Transform3D = world.build._free_xf("floor", site + b * Vector3(-1, 0, -1), yaw, true)
|
|
outline = HutOutline.new()
|
|
outline.name = "HutOutline"
|
|
world.add_child(outline)
|
|
outline.setup(world, first)
|
|
world.build.outline = outline
|
|
if su.get("demo_wolf", false):
|
|
if demo_wolf != null and is_instance_valid(demo_wolf):
|
|
demo_wolf.queue_free()
|
|
demo_wolf = DemoWolf.new()
|
|
demo_wolf.setup("wolf", world, world.player.global_position + Vector3(20, 0, 0), 4242)
|
|
world.add_child(demo_wolf)
|
|
demo_wolf.begin(world.player)
|
|
demo_wolf.gone.connect(func(): _count("demo_wolf:true", 1))
|
|
|
|
## The player read a "Continue" step.
|
|
func continue_step() -> void:
|
|
_count("continue:true", 1)
|
|
|
|
## Skips the current step (pause menu, T2).
|
|
func skip() -> void:
|
|
if not current().is_empty():
|
|
_advance()
|
|
|
|
func _count(key: String, n: float) -> void:
|
|
counters[key] = float(counters.get(key, 0.0)) + n
|
|
if active:
|
|
progressed.emit()
|
|
_check()
|
|
|
|
func _check() -> void:
|
|
var s := current()
|
|
if s.is_empty():
|
|
return
|
|
if _met(s.goal):
|
|
_advance()
|
|
|
|
func _advance() -> void:
|
|
_clear_outline() # (it was only for the step that's ending)
|
|
var s := current()
|
|
done.append(String(s.id))
|
|
step_done.emit(index, s)
|
|
index += 1
|
|
# A "continue" only counts for the step it was pressed on.
|
|
counters.erase("continue:true")
|
|
_enter()
|
|
|
|
# --- goals -------------------------------------------------------------------------------
|
|
|
|
## [[label, have, need], ...] for each part of `goal`.
|
|
func parts(goal: Dictionary) -> Array:
|
|
if goal.has("any"):
|
|
var names := []
|
|
var met := false
|
|
for g in goal.any:
|
|
for pp in parts(g):
|
|
names.append(String(pp[0]))
|
|
met = met or _met(g)
|
|
return [[" or ".join(PackedStringArray(names)), 1.0 if met else 0.0, 1.0]]
|
|
if goal.has("all"):
|
|
var out := []
|
|
for g in goal.all:
|
|
out.append_array(parts(g))
|
|
return out
|
|
var need := float(goal.get("count", 1))
|
|
for k in ["gained", "crafted", "felled", "placed", "ate", "killed", "butchered", "caught", "slept", "continue",
|
|
"shelter", "bed_spawn", "demo_wolf"]:
|
|
if goal.has(k):
|
|
var key := "%s:%s" % [k, str(goal[k]).to_lower() if goal[k] is bool else str(goal[k])] # (true -> "true")
|
|
return [[_label(k, goal), float(counters.get(key, 0.0)), need]]
|
|
if goal.has("walk"):
|
|
return [["Walk", floorf(float(counters.get("walk", 0.0))), float(goal.walk)]]
|
|
return []
|
|
|
|
func _label(kind: String, goal: Dictionary) -> String:
|
|
if goal.has("label"):
|
|
return String(goal.label)
|
|
match kind:
|
|
"gained": return _plural(ItemDB.item_name(String(goal.gained)), int(goal.get("count", 1)))
|
|
"crafted":
|
|
var r: Dictionary = ItemDB.recipes.get(String(goal.crafted), {})
|
|
return ItemDB.item_name(String(r.get("output", goal.crafted)))
|
|
"felled": return "Tree" if goal.felled == "tree" else "Boulder"
|
|
"continue", "shelter", "bed_spawn", "demo_wolf": return ""
|
|
"placed": return ItemDB.item_name(String(goal.placed)) + " placed"
|
|
"ate": return "Eaten"
|
|
"killed": return String(goal.killed).capitalize()
|
|
"butchered": return "Butchered"
|
|
"caught": return "A fish"
|
|
"slept": return "Slept"
|
|
return kind.capitalize()
|
|
|
|
static func _plural(name: String, n: int) -> String:
|
|
if n <= 1 or name.ends_with("s") or name.to_lower() in ["fiber", "fibre", "wood", "stone", "flint", "clay", "salt"]:
|
|
return name
|
|
return name + "s"
|
|
|
|
func _met(goal: Dictionary) -> bool:
|
|
for p in parts(goal):
|
|
if float(p[1]) < float(p[2]):
|
|
return false
|
|
return true
|
|
|
|
## "Sticks 2/3 · Stone 1/3" for the current step (empty for one-off goals).
|
|
func progress_text() -> String:
|
|
var s := current()
|
|
if s.is_empty():
|
|
return ""
|
|
var bits := []
|
|
for p in parts(s.goal):
|
|
if float(p[2]) > 1.0:
|
|
bits.append("%s %d/%d" % [p[0], mini(int(p[1]), int(p[2])), int(p[2])])
|
|
elif not String(p[0]).is_empty():
|
|
bits.append(("✓ " if float(p[1]) >= 1.0 else "") + String(p[0]))
|
|
return " · ".join(PackedStringArray(bits))
|
|
|
|
# --- walking and the marker ------------------------------------------------------------
|
|
|
|
func _process(delta: float) -> void:
|
|
if not active or world == null or not is_instance_valid(world) or world.player == null:
|
|
return
|
|
var p: Vector3 = world.player.global_position
|
|
if _last_pos != Vector3.INF:
|
|
var d := Vector2(p.x - _last_pos.x, p.z - _last_pos.z).length()
|
|
if d > 0.01 and d < 5.0: # (not a teleport or a respawn)
|
|
_count("walk", d)
|
|
_last_pos = p
|
|
_marker_t -= delta
|
|
if _marker_t <= 0.0:
|
|
_marker_t = 0.5
|
|
_marker = find_marker()
|
|
poll()
|
|
|
|
## Goals that are states, not events: checked twice a second.
|
|
func poll() -> void:
|
|
if not active or world == null or not is_instance_valid(world):
|
|
return
|
|
var pl: Node = world.player
|
|
if pl.survival.sheltered and float(counters.get("shelter:true", 0.0)) < 1.0:
|
|
_count("shelter:true", 1)
|
|
if world.has_bed_spawn and float(counters.get("bed_spawn:true", 0.0)) < 1.0:
|
|
_count("bed_spawn:true", 1)
|
|
|
|
## Called by tests (no frames): moves the walk counter on by `metres`.
|
|
func walked(metres: float) -> void:
|
|
_count("walk", metres)
|
|
|
|
func marker_position() -> Vector3:
|
|
return _marker
|
|
|
|
## Where the current step's marker goes: the nearest thing it asks for, or
|
|
## INF (no marker, or you've got what it was for).
|
|
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)
|
|
elif what == "bush":
|
|
for b in world.props.forage.values():
|
|
if is_instance_valid(b) and b.ripe():
|
|
cands.append(b)
|
|
elif what.begins_with("creature:"):
|
|
for c in world.creatures.alive(what.substr(9)):
|
|
cands.append(c)
|
|
elif what == "carcass":
|
|
for c in world.get_tree().get_nodes_in_group("carcass"):
|
|
cands.append(c)
|
|
elif what.begins_with("piece:"):
|
|
for pc in world.all_pieces():
|
|
if pc.item_id == what.substr(6):
|
|
cands.append(pc)
|
|
elif what == "outline":
|
|
# Wherever the outline still has pieces to build: its middle.
|
|
if outline != null and is_instance_valid(outline):
|
|
return outline.center()
|
|
return Vector3.INF
|
|
elif what == "lake":
|
|
# The lake's shore, on your side of it.
|
|
var c: Vector2 = world.terrain.lake_center
|
|
var dir := Vector2(from.x - c.x, from.z - c.y)
|
|
dir = dir.normalized() if dir.length() > 0.1 else Vector2(1, 0)
|
|
var r: float = world.terrain.lake_radius * 0.6
|
|
while r < world.terrain.lake_radius * 2.0 and world.terrain.sample_height(c.x + dir.x * r, c.y + dir.y * r) < world.terrain.water_level + 0.3:
|
|
r += 0.5
|
|
var sx: float = c.x + dir.x * r
|
|
var sz: float = c.y + dir.y * r
|
|
return Vector3(sx, world.terrain.sample_height(sx, sz), sz)
|
|
for c in cands:
|
|
var d: float = (c as Node3D).global_position.distance_to(from)
|
|
if d < best_d:
|
|
best_d = d
|
|
best = (c as Node3D).global_position
|
|
return best
|
|
|
|
# --- saving --------------------------------------------------------------------------------
|
|
|
|
func save_data() -> Dictionary:
|
|
return {"index": index, "done": done.duplicate(), "counters": counters.duplicate()} if active else {}
|
|
|
|
func load_data(d: Dictionary) -> void:
|
|
index = clampi(int(d.get("index", 0)), 0, steps.size())
|
|
done = (d.get("done", []) as Array).duplicate()
|
|
counters = (d.get("counters", {}) as Dictionary).duplicate()
|