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:
@@ -10,6 +10,17 @@ extends Node
|
||||
|
||||
signal notify(text: String) ## short HUD toast ("+4 Wood", "Need an axe")
|
||||
signal item_gained(id: String, n: int)
|
||||
## What players did (docs/TUTORIAL_DESIGN.md §3): the tutorial's Guide (and
|
||||
## later hints and addons) listen to these. Emitted where the game decides
|
||||
## (single-player, or on a server for each player's character).
|
||||
signal crafted(player: Node, recipe_id: String, output: String, count: int)
|
||||
signal harvested(player: Node, node: Node, felled: bool)
|
||||
signal placed(player: Node, item_id: String, piece: Node)
|
||||
signal ate(player: Node, item_id: String)
|
||||
signal killed(player: Node, kind: String)
|
||||
signal butchered(player: Node, kind: String)
|
||||
signal caught(player: Node, fish: String)
|
||||
signal slept(player: Node)
|
||||
|
||||
const STATION_RANGE := 6.0
|
||||
const REACH := 3.2
|
||||
@@ -207,6 +218,7 @@ func craft(player: Node, recipe_id: String) -> bool:
|
||||
for id in r.inputs:
|
||||
inv.remove(id, int(r.inputs[id]))
|
||||
_give(player, r.output, r.count)
|
||||
crafted.emit(player, recipe_id, String(r.output), int(r.count))
|
||||
return true
|
||||
|
||||
func pickup(player: Node3D, pickup_node: Node3D) -> bool:
|
||||
@@ -250,6 +262,7 @@ func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool:
|
||||
for id in drops:
|
||||
_give(player, id, int(drops[id]))
|
||||
use_held(player)
|
||||
harvested.emit(player, node, bool(node.depleted))
|
||||
return true
|
||||
|
||||
func can_place(player: Node3D, slot: int) -> bool:
|
||||
@@ -271,7 +284,8 @@ func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool:
|
||||
notify.emit("Can't place that here")
|
||||
return false
|
||||
player.inventory.take_from(slot, 1)
|
||||
world.spawn_piece(def, xf)
|
||||
var piece: Node3D = world.spawn_piece(def, xf)
|
||||
placed.emit(player, id, piece)
|
||||
return true
|
||||
|
||||
## Takes a placed piece back into your inventory. Building pieces that lose
|
||||
@@ -369,6 +383,7 @@ func use_bed(player: Node3D, bed: Node3D) -> bool:
|
||||
notify.emit(why)
|
||||
return false
|
||||
world.sleep(player)
|
||||
slept.emit(player)
|
||||
return true
|
||||
|
||||
# --- hunting and combat ---------------------------------------------------------
|
||||
@@ -420,6 +435,7 @@ func melee(player: Node3D, target: Node3D) -> bool:
|
||||
use_held(player)
|
||||
if target.is_dead():
|
||||
notify.emit("Killed a %s" % target.display_name().to_lower())
|
||||
killed.emit(player, String(target.kind))
|
||||
return true
|
||||
|
||||
## A melee blow at another player (online, with PvP on). Online only.
|
||||
@@ -465,6 +481,7 @@ func projectile_hit(shooter: Node3D, target: Node3D, damage: float, at: Vector3,
|
||||
target.take_hit(damage, shooter.global_position if shooter != null else at, embed_id)
|
||||
if was_alive and target.is_dead():
|
||||
tell(shooter, "Killed a %s" % target.display_name().to_lower())
|
||||
killed.emit(shooter, String(target.kind))
|
||||
|
||||
## Throws the spear in `slot` from `origin` along `dir`.
|
||||
func throw_spear(player: Node3D, slot: int, origin: Vector3, dir: Vector3, world: Node3D) -> bool:
|
||||
@@ -540,6 +557,7 @@ func butcher(player: Node3D, carcass: Node3D) -> bool:
|
||||
return false
|
||||
var drops: Dictionary = carcass.butcher_drops()
|
||||
carcass.remove_from_group("carcass")
|
||||
butchered.emit(player, String(carcass.kind))
|
||||
for id in drops:
|
||||
var got := _give(player, id, int(drops[id]))
|
||||
if got < int(drops[id]):
|
||||
@@ -584,6 +602,7 @@ func reel(player: Node3D, world: Node3D) -> bool:
|
||||
if _give(player, caught, 1) == 0:
|
||||
world.drop_item(caught, 1, Transform3D(Basis(), player.global_position + Vector3(0, 0.1, 0)))
|
||||
notify.emit("Caught a %s" % ItemDB.item_name(caught).to_lower())
|
||||
emit_signal("caught", player, caught) # (the local `caught` hides the signal's name)
|
||||
return true
|
||||
|
||||
## Puts on the cloak in `slot` (swapping out any cloak you were wearing).
|
||||
@@ -642,8 +661,10 @@ func eat(player: Node3D, slot: int) -> bool:
|
||||
notify.emit("You're full")
|
||||
return false
|
||||
player.inventory.take_from(slot, 1)
|
||||
var eaten := String(s.id)
|
||||
player.survival.eat(food)
|
||||
notify.emit("Ate %s" % ItemDB.item_name(s.id).to_lower())
|
||||
notify.emit("Ate %s" % ItemDB.item_name(eaten).to_lower())
|
||||
ate.emit(player, eaten)
|
||||
# Raw meat and fish can make you ill (P4). Cooked, smoked or salted can't.
|
||||
if food_rng.randf() < float(food.get("risk", 0.0)):
|
||||
player.survival.poison()
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user