T2 tutorial: all five chapters with Old Tam, scripted dusk and demo wolf, rabbits for the hunt, title-screen Tutorial entry, pause-menu skip/leave, goodbye card; full bot playthrough test
This commit is contained in:
@@ -14,10 +14,18 @@ extends Node
|
||||
## {"felled": "tree" | "rock"} chopped down / broken
|
||||
## {"placed": item_id}, {"ate": item_id or "any"}, {"killed": kind},
|
||||
## {"butchered": kind}, {"caught": "any"}, {"slept": true}
|
||||
## {"continue": true} the player pressed Continue
|
||||
## {"continue": true} the player pressed Enter ("Continue")
|
||||
## {"shelter": true} standing under a roof with walls round you
|
||||
## {"bed_spawn": true} you've set your spawn at a bed
|
||||
## {"demo_wolf": true} the tutorial's wolf has been and gone
|
||||
## {"all": [goal, goal, ...]} every one of them
|
||||
## Markers: {"nearest": "pickup:<id>" | "tree" | "rock"}, optionally
|
||||
## {"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.
|
||||
@@ -90,10 +98,12 @@ func _load_data() -> void:
|
||||
steps = d.get("steps", [])
|
||||
chapters = d.get("chapters", [])
|
||||
guide_name = String(d.get("guide", {}).get("name", ""))
|
||||
finish = d.get("finish", {})
|
||||
|
||||
## Who's talking you through it (data/tutorial.json "guide": Old Tam, the
|
||||
## island's hermit).
|
||||
var guide_name := ""
|
||||
var finish: Dictionary = {} ## the card at the end: title and text
|
||||
|
||||
# --- running it -----------------------------------------------------------------------
|
||||
|
||||
@@ -129,10 +139,44 @@ func _enter() -> void:
|
||||
if index >= steps.size():
|
||||
finished.emit()
|
||||
return
|
||||
_setup(steps[index].get("setup", {}))
|
||||
step_started.emit(index, steps[index])
|
||||
_marker_t = 0.0
|
||||
_check() # (done early? it ticks straight away)
|
||||
|
||||
const DemoWolf = preload("res://scripts/creatures/tutorial_wolf.gd")
|
||||
var demo_wolf: Node3D = null
|
||||
|
||||
## A step's set-up: the clock, some animals, the demo wolf.
|
||||
func _setup(su: Dictionary) -> void:
|
||||
if su.is_empty() or world == null or not is_instance_valid(world):
|
||||
return
|
||||
if su.has("time"):
|
||||
var want: float = float(su.time) / 24.0
|
||||
if world.day_night.time < want:
|
||||
world.day_night.time = want
|
||||
world.day_night.apply(true)
|
||||
if su.has("spawn"):
|
||||
var sp: Dictionary = su.spawn
|
||||
var p: Vector3 = world.player.global_position
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = int(p.x * 13.0 + p.z * 7.0)
|
||||
for i in range(int(sp.get("count", 1))):
|
||||
for _try in range(12):
|
||||
var a := rng.randf() * TAU
|
||||
var at := p + Vector3(cos(a), 0, sin(a)) * float(sp.get("distance", 12.0))
|
||||
if world.terrain.inside_boundary(at.x, at.z, 4.0) and world.terrain.sample_height(at.x, at.z) > world.terrain.water_level + 0.3:
|
||||
world.creatures.spawn(String(sp.kind), at)
|
||||
break
|
||||
if su.get("demo_wolf", false):
|
||||
if demo_wolf != null and is_instance_valid(demo_wolf):
|
||||
demo_wolf.queue_free()
|
||||
demo_wolf = DemoWolf.new()
|
||||
demo_wolf.setup("wolf", world, world.player.global_position + Vector3(20, 0, 0), 4242)
|
||||
world.add_child(demo_wolf)
|
||||
demo_wolf.begin(world.player)
|
||||
demo_wolf.gone.connect(func(): _count("demo_wolf:true", 1))
|
||||
|
||||
## The player read a "Continue" step.
|
||||
func continue_step() -> void:
|
||||
_count("continue:true", 1)
|
||||
@@ -168,15 +212,24 @@ func _advance() -> void:
|
||||
|
||||
## [[label, have, need], ...] for each part of `goal`.
|
||||
func parts(goal: Dictionary) -> Array:
|
||||
if goal.has("any"):
|
||||
var names := []
|
||||
var met := false
|
||||
for g in goal.any:
|
||||
for pp in parts(g):
|
||||
names.append(String(pp[0]))
|
||||
met = met or _met(g)
|
||||
return [[" or ".join(PackedStringArray(names)), 1.0 if met else 0.0, 1.0]]
|
||||
if goal.has("all"):
|
||||
var out := []
|
||||
for g in goal.all:
|
||||
out.append_array(parts(g))
|
||||
return out
|
||||
var need := float(goal.get("count", 1))
|
||||
for k in ["gained", "crafted", "felled", "placed", "ate", "killed", "butchered", "caught", "slept", "continue"]:
|
||||
for k in ["gained", "crafted", "felled", "placed", "ate", "killed", "butchered", "caught", "slept", "continue",
|
||||
"shelter", "bed_spawn", "demo_wolf"]:
|
||||
if goal.has(k):
|
||||
var key := "%s:%s" % [k, String(goal[k]).to_lower() if goal[k] is bool else String(goal[k])]
|
||||
var key := "%s:%s" % [k, str(goal[k]).to_lower() if goal[k] is bool else str(goal[k])] # (true -> "true")
|
||||
return [[_label(k, goal), float(counters.get(key, 0.0)), need]]
|
||||
if goal.has("walk"):
|
||||
return [["Walk", floorf(float(counters.get("walk", 0.0))), float(goal.walk)]]
|
||||
@@ -191,7 +244,13 @@ func _label(kind: String, goal: Dictionary) -> String:
|
||||
var r: Dictionary = ItemDB.recipes.get(String(goal.crafted), {})
|
||||
return ItemDB.item_name(String(r.get("output", goal.crafted)))
|
||||
"felled": return "Tree" if goal.felled == "tree" else "Boulder"
|
||||
"continue": return ""
|
||||
"continue", "shelter", "bed_spawn", "demo_wolf": return ""
|
||||
"placed": return ItemDB.item_name(String(goal.placed)) + " placed"
|
||||
"ate": return "Eaten"
|
||||
"killed": return String(goal.killed).capitalize()
|
||||
"butchered": return "Butchered"
|
||||
"caught": return "A fish"
|
||||
"slept": return "Slept"
|
||||
return kind.capitalize()
|
||||
|
||||
static func _plural(name: String, n: int) -> String:
|
||||
@@ -233,6 +292,17 @@ func _process(delta: float) -> void:
|
||||
if _marker_t <= 0.0:
|
||||
_marker_t = 0.5
|
||||
_marker = find_marker()
|
||||
poll()
|
||||
|
||||
## Goals that are states, not events: checked twice a second.
|
||||
func poll() -> void:
|
||||
if not active or world == null or not is_instance_valid(world):
|
||||
return
|
||||
var pl: Node = world.player
|
||||
if pl.survival.sheltered and float(counters.get("shelter:true", 0.0)) < 1.0:
|
||||
_count("shelter:true", 1)
|
||||
if world.has_bed_spawn and float(counters.get("bed_spawn:true", 0.0)) < 1.0:
|
||||
_count("bed_spawn:true", 1)
|
||||
|
||||
## Called by tests (no frames): moves the walk counter on by `metres`.
|
||||
func walked(metres: float) -> void:
|
||||
@@ -265,6 +335,31 @@ func find_marker() -> Vector3:
|
||||
for n in world.props.resource_nodes.values():
|
||||
if is_instance_valid(n) and n.kind == what and not n.depleted:
|
||||
cands.append(n)
|
||||
elif what == "bush":
|
||||
for b in world.props.forage.values():
|
||||
if is_instance_valid(b) and b.ripe():
|
||||
cands.append(b)
|
||||
elif what.begins_with("creature:"):
|
||||
for c in world.creatures.alive(what.substr(9)):
|
||||
cands.append(c)
|
||||
elif what == "carcass":
|
||||
for c in world.get_tree().get_nodes_in_group("carcass"):
|
||||
cands.append(c)
|
||||
elif what.begins_with("piece:"):
|
||||
for pc in world.all_pieces():
|
||||
if pc.item_id == what.substr(6):
|
||||
cands.append(pc)
|
||||
elif what == "lake":
|
||||
# The lake's shore, on your side of it.
|
||||
var c: Vector2 = world.terrain.lake_center
|
||||
var dir := Vector2(from.x - c.x, from.z - c.y)
|
||||
dir = dir.normalized() if dir.length() > 0.1 else Vector2(1, 0)
|
||||
var r: float = world.terrain.lake_radius * 0.6
|
||||
while r < world.terrain.lake_radius * 2.0 and world.terrain.sample_height(c.x + dir.x * r, c.y + dir.y * r) < world.terrain.water_level + 0.3:
|
||||
r += 0.5
|
||||
var sx: float = c.x + dir.x * r
|
||||
var sz: float = c.y + dir.y * r
|
||||
return Vector3(sx, world.terrain.sample_height(sx, sz), sz)
|
||||
for c in cands:
|
||||
var d: float = (c as Node3D).global_position.distance_to(from)
|
||||
if d < best_d:
|
||||
|
||||
79
scripts/creatures/tutorial_wolf.gd
Normal file
79
scripts/creatures/tutorial_wolf.gd
Normal file
@@ -0,0 +1,79 @@
|
||||
extends "res://scripts/creatures/creature.gd"
|
||||
## The tutorial's wolf (docs/TUTORIAL_DESIGN.md §2, step "Wolves"): it comes
|
||||
## out of the dark, circles at the edge of the light for a while, and slinks
|
||||
## off. It never attacks. When it's gone, `gone` fires and the step ticks.
|
||||
|
||||
signal gone
|
||||
|
||||
const RING := 11.0
|
||||
const CIRCLE_SECONDS := 18.0
|
||||
const LEAVE_AFTER := 40.0 ## this far from you it's gone
|
||||
|
||||
var target: Node3D
|
||||
var _a := 0.0
|
||||
var _goal_d := 0.0
|
||||
var _done := false
|
||||
|
||||
func begin(t: Node3D) -> void:
|
||||
target = t
|
||||
var away: Vector3 = global_position - t.global_position
|
||||
_a = atan2(away.z, away.x)
|
||||
state = "stalk"
|
||||
_state_t = CIRCLE_SECONDS
|
||||
|
||||
func display_name() -> String:
|
||||
return "Wolf"
|
||||
|
||||
func interact_prompt(_p: Node) -> String:
|
||||
return "Wolf (watching you from the dark)"
|
||||
|
||||
func _pick_target() -> Node3D:
|
||||
return target
|
||||
|
||||
func _think(dt: float) -> void:
|
||||
if target == null or not is_instance_valid(target):
|
||||
_leave()
|
||||
return
|
||||
_state_t -= dt
|
||||
var tp: Vector3 = target.global_position
|
||||
match state:
|
||||
"stalk":
|
||||
_a += dt * 0.35
|
||||
_go_to(tp + Vector3(cos(_a), 0, sin(_a)) * RING)
|
||||
if _state_t <= 0.0:
|
||||
state = "flee"
|
||||
_state_t = 15.0
|
||||
"flee":
|
||||
var away := global_position - tp
|
||||
away.y = 0.0
|
||||
steer(atan2(-away.x, -away.z))
|
||||
_goal_d = 99.0
|
||||
if global_position.distance_to(tp) > LEAVE_AFTER or _state_t <= 0.0:
|
||||
_leave()
|
||||
_:
|
||||
state = "stalk"
|
||||
|
||||
func _go_to(p: Vector3) -> void:
|
||||
var to := p - global_position
|
||||
to.y = 0.0
|
||||
if to.length() > 0.3:
|
||||
steer(atan2(-to.x, -to.z))
|
||||
_goal_d = to.length()
|
||||
|
||||
func _desired_speed() -> float:
|
||||
match state:
|
||||
"stalk": return 0.0 if _goal_d < 1.0 else (3.0 if _goal_d < 6.0 else 6.5)
|
||||
"flee": return 7.5
|
||||
return 0.0
|
||||
|
||||
func _leave() -> void:
|
||||
if _done:
|
||||
return
|
||||
_done = true
|
||||
gone.emit()
|
||||
queue_free()
|
||||
|
||||
## (A swipe makes it run.)
|
||||
func _react(_from: Vector3) -> void:
|
||||
state = "flee"
|
||||
_state_t = 10.0
|
||||
@@ -61,9 +61,14 @@ func setup(p: Node3D) -> void:
|
||||
_redraw()
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if Guide.active and event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_H:
|
||||
if not Guide.active or not (event is InputEventKey) or not event.pressed or event.echo:
|
||||
return
|
||||
if event.keycode == KEY_H:
|
||||
_hidden = not _hidden
|
||||
_redraw()
|
||||
elif event.keycode in [KEY_ENTER, KEY_KP_ENTER] and Guide.current().get("goal", {}).has("continue"):
|
||||
Guide.continue_step()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
func _redraw() -> void:
|
||||
var s: Dictionary = Guide.current()
|
||||
@@ -84,13 +89,62 @@ func _on_done(_i: int, s: Dictionary) -> void:
|
||||
tw.tween_interval(1.4)
|
||||
tw.tween_property(_tick, "modulate:a", 0.0, 0.6)
|
||||
|
||||
## The end: Old Tam's summary, and Start your own world / Keep exploring.
|
||||
var card: PanelContainer
|
||||
|
||||
func _on_finished() -> void:
|
||||
_box.visible = false
|
||||
_tick.text = "✓ Chapter done. More of the tutorial is on its way."
|
||||
_tick.modulate.a = 1.0
|
||||
var tw := create_tween()
|
||||
tw.tween_interval(3.0)
|
||||
tw.tween_property(_tick, "modulate:a", 0.0, 1.0)
|
||||
if card != null and is_instance_valid(card):
|
||||
return
|
||||
card = UITheme.panel()
|
||||
card.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
|
||||
card.grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||
card.grow_vertical = Control.GROW_DIRECTION_BOTH
|
||||
card.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
add_child(card)
|
||||
var v := VBoxContainer.new()
|
||||
v.add_theme_constant_override("separation", 10)
|
||||
v.custom_minimum_size = Vector2(520, 0)
|
||||
card.add_child(v)
|
||||
var f: Dictionary = Guide.finish
|
||||
v.add_child(UITheme.label(String(f.get("title", "Tutorial done")), 26))
|
||||
var t := UITheme.label("%s: \"%s\"" % [Guide.guide_name, String(f.get("text", ""))] if not Guide.guide_name.is_empty() else String(f.get("text", "")), 16, UITheme.TEXT_DIM)
|
||||
t.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
t.custom_minimum_size = Vector2(520, 0)
|
||||
v.add_child(t)
|
||||
var h := HBoxContainer.new()
|
||||
h.add_theme_constant_override("separation", 10)
|
||||
v.add_child(h)
|
||||
for b in [["Start your own world", _on_new_world], ["Keep exploring", _on_keep_exploring]]:
|
||||
var btn := Button.new()
|
||||
btn.text = b[0]
|
||||
btn.focus_mode = Control.FOCUS_NONE
|
||||
btn.custom_minimum_size = Vector2(240, 44)
|
||||
btn.add_theme_font_size_override("font_size", 18)
|
||||
btn.pressed.connect(b[1])
|
||||
h.add_child(btn)
|
||||
if player != null and player.get("ui_open") != null:
|
||||
player.ui_open = true
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
|
||||
func _on_keep_exploring() -> void:
|
||||
if card != null:
|
||||
card.queue_free()
|
||||
card = null
|
||||
if player != null and player.get("ui_open") != null:
|
||||
player.ui_open = false
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
|
||||
## Saves the tutorial world and goes to the title screen's New World page.
|
||||
func _on_new_world() -> void:
|
||||
if Guide.world != null and is_instance_valid(Guide.world):
|
||||
Guide.world.save_now()
|
||||
get_tree().paused = false
|
||||
Saves.current_id = ""
|
||||
get_tree().auto_accept_quit = true
|
||||
var TS: Script = load("res://scripts/ui/title_screen.gd")
|
||||
TS.start_page = "new"
|
||||
get_tree().change_scene_to_file("res://scenes/title.tscn")
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
_update_marker()
|
||||
|
||||
@@ -80,6 +80,11 @@ func bind(p: CharacterBody3D) -> void:
|
||||
sens_row.add_child(sens)
|
||||
_main.add_child(sens_row)
|
||||
|
||||
# The tutorial: skip a step, or leave it (saved, to carry on later).
|
||||
if Guide.active:
|
||||
_main.add_child(_button("Skip this tutorial step", func(): Guide.skip(); close()))
|
||||
_main.add_child(_button("Leave the tutorial (carry on later)", _on_quit_to_title))
|
||||
|
||||
var saved: bool = Saves.has_world()
|
||||
_online = Net.state == "connected"
|
||||
if _online:
|
||||
|
||||
@@ -9,6 +9,8 @@ var _pages: Dictionary = {} ## name -> Control
|
||||
var _name_edit: LineEdit
|
||||
var _seed_edit: LineEdit
|
||||
var _rot_pick: OptionButton
|
||||
## The page to open on (the tutorial's "Start your own world" sends you to "new").
|
||||
static var start_page := ""
|
||||
## [setting, what the New World page calls it]
|
||||
const ROT_CHOICES := [["normal", "Normally (raw meat lasts 2 days)"], ["slow", "Slowly (twice as long)"], ["off", "Never"]]
|
||||
var _list: VBoxContainer
|
||||
@@ -100,7 +102,8 @@ func _ready() -> void:
|
||||
Net.players_changed.connect(func():
|
||||
if _pages["lobby"].visible:
|
||||
_fill_lobby())
|
||||
_show("main")
|
||||
_show(start_page if start_page in _pages else "main")
|
||||
start_page = ""
|
||||
# Back from a server (left, kicked, or it went away): say why.
|
||||
if Net.state == "offline" and not Net.last_reason.is_empty():
|
||||
_show("join")
|
||||
@@ -202,9 +205,13 @@ func _main_page() -> Control:
|
||||
var v := VBoxContainer.new()
|
||||
v.add_theme_constant_override("separation", 10)
|
||||
var worlds := Saves.list_worlds()
|
||||
if not worlds.is_empty():
|
||||
if worlds.is_empty():
|
||||
v.add_child(UITheme.label("New here? Try the tutorial (about 20 minutes).", 15, UITheme.TEXT_DIM))
|
||||
else:
|
||||
var w: Dictionary = worlds[0]
|
||||
v.add_child(_button("Continue: %s" % w.name, _play.bind(w.id)))
|
||||
var tut := tutorial_world()
|
||||
v.add_child(_button("Tutorial" if tut.is_empty() else "Tutorial (carry on)", _play_tutorial))
|
||||
v.add_child(_button("New World", _show.bind("new")))
|
||||
var lb := _button("Load World", _show.bind("load"))
|
||||
lb.disabled = worlds.is_empty()
|
||||
@@ -673,6 +680,20 @@ func _create() -> void:
|
||||
var rot: String = ROT_CHOICES[maxi(_rot_pick.selected, 0)][0]
|
||||
_play(Saves.create_world(world_name, seed, {"rot": rot}))
|
||||
|
||||
## The tutorial (docs/TUTORIAL_DESIGN.md): its own world on the original
|
||||
## island (seed 1337), made the first time and carried on after that.
|
||||
const TUTORIAL_SEED := 1337
|
||||
|
||||
static func tutorial_world() -> Dictionary:
|
||||
for w in Saves.list_worlds():
|
||||
if bool(w.get("tutorial", false)):
|
||||
return w
|
||||
return {}
|
||||
|
||||
func _play_tutorial() -> void:
|
||||
var w := tutorial_world()
|
||||
_play(String(w.id) if not w.is_empty() else Saves.create_world("Tutorial", TUTORIAL_SEED, {"tutorial": true}))
|
||||
|
||||
func _play(id: String) -> void:
|
||||
Saves.current_id = id
|
||||
get_tree().change_scene_to_file(WORLD_SCENE)
|
||||
|
||||
@@ -416,11 +416,15 @@ func _on_terrain_ready() -> void:
|
||||
wolves.name = "Wolves"
|
||||
add_child(wolves)
|
||||
wolves.setup(self, den)
|
||||
if not "--no-creatures" in OS.get_cmdline_user_args():
|
||||
_setup_bear()
|
||||
# The tutorial (docs/TUTORIAL_DESIGN.md): a world made for it (meta.json
|
||||
# "tutorial"), or any world with `-- --tutorial` (for now, T1).
|
||||
if bool(Saves.read_meta(Saves.current_id).get("tutorial", false)) or "--tutorial" in OS.get_cmdline_user_args():
|
||||
# "tutorial"), or any world with `-- --tutorial`. Its island is calm: no
|
||||
# wolf pack (just Old Tam's demo wolf), no bear, nothing stealing your food.
|
||||
var tutorial := bool(Saves.read_meta(Saves.current_id).get("tutorial", false)) or "--tutorial" in OS.get_cmdline_user_args()
|
||||
if tutorial and wolves != null:
|
||||
wolves.enabled = false
|
||||
if not "--no-creatures" in OS.get_cmdline_user_args() and not tutorial:
|
||||
_setup_bear()
|
||||
if tutorial:
|
||||
Guide.start(self, _saved_tutorial)
|
||||
_maybe_capture()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user