252 lines
10 KiB
GDScript
252 lines
10 KiB
GDScript
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")
|
|
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 %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)")
|
|
|
|
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(" Old Tam's voice")
|
|
var NR: Node = root.get_node("Narrator")
|
|
check(NR.spoken.has("tutorial_look"), "he reads the first step aloud")
|
|
var TD: Dictionary = JSON.parse_string(FileAccess.get_file_as_string("res://data/tutorial.json"))
|
|
var HD: Dictionary = JSON.parse_string(FileAccess.get_file_as_string("res://data/hints.json"))
|
|
var stale: Array = []
|
|
for s in TD.steps:
|
|
if not NR.has_line("tutorial_" + String(s.id), String(s.text)):
|
|
stale.append(s.id)
|
|
if not NR.has_line("tutorial_finish", String(TD.finish.text)):
|
|
stale.append("finish")
|
|
for h in HD.hints:
|
|
if not NR.has_line("hint_" + String(h.id), String(h.text)):
|
|
stale.append("hint " + String(h.id))
|
|
check(stale.is_empty(), "every line he says is recorded, from the text as it is now (else run tools/voice/make_voice.sh) %s" % [stale])
|
|
check(not NR.say("tutorial_look", "Some other words"), "(changed words: he stays quiet rather than say the old ones)")
|
|
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
|
|
var near_stone := false
|
|
for pk in world.props.pickups.values():
|
|
if pk.item_id == "stone" and not pk.taken and pk.global_position.distance_to(m) < 0.01:
|
|
near_stone = true
|
|
check(m != Vector3.INF and (near_stick or near_stone), "the marker stands on the nearest stick or stone")
|
|
# 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"), "a third stick")
|
|
G._marker = G.find_marker()
|
|
var on_stone := false
|
|
for pk in world.props.pickups.values():
|
|
if pk.item_id == "stone" and not pk.taken and pk.global_position.distance_to(G.marker_position()) < 0.01:
|
|
on_stone = true
|
|
check(on_stone, "enough sticks: now the marker's on a stone")
|
|
check(_take("stone") and _take("stone"), "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(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")
|
|
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")
|