T4: Old Tam's tips in normal worlds, with a setting
This commit is contained in:
38
data/hints.json
Normal file
38
data/hints.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"guide": "Old Tam",
|
||||||
|
"_doc": "T4 (docs/TUTORIAL_DESIGN.md §5): one-off tips in normal worlds, each shown once per player (user://hints.json), in list order when several are due. 'when' is kind:value, with | for alternatives: status (a survival status, by prefix), weather (a state), has (an item in your bag), event (howl, died), or a plain check (night, stamina_low, wolf_moon, hard_winter). 'hold' is how long it must stay true first (seconds, default 1).",
|
||||||
|
"hints": [
|
||||||
|
{"id": "died", "when": "event:died", "title": "Back on your feet",
|
||||||
|
"text": "Dying's not the end out here. Whatever you carried is waiting where you fell, so go back for it before anything else. Sleep in a bed once and that's where you'll wake."},
|
||||||
|
{"id": "freezing", "when": "status:Freezing", "title": "Freezing",
|
||||||
|
"text": "You're freezing, and that eats at your health. Get to a fire or under a roof, now. A cloak helps too."},
|
||||||
|
{"id": "cold", "when": "status:Cold", "hold": 3, "title": "Getting cold",
|
||||||
|
"text": "Feel that chill? A fire warms you, a roof and walls keep the wind off, and a hide cloak buys you time."},
|
||||||
|
{"id": "sick", "when": "status:Food poisoning", "title": "Bad food",
|
||||||
|
"text": "Raw meat or something past its best: your belly's turned. It passes. Rest, don't eat for a bit, and cook your meat next time."},
|
||||||
|
{"id": "rotten", "when": "has:rotten_food", "title": "Food goes off",
|
||||||
|
"text": "Food doesn't keep forever. Eat the fresh stuff first, smoke meat and fish on a rack, or salt it. Rotten food's no good to anyone: throw it out."},
|
||||||
|
{"id": "wet", "when": "status:Wet", "hold": 2, "title": "Soaked",
|
||||||
|
"text": "Wet clothes steal your warmth. Stand under a roof or by a fire and you'll dry off."},
|
||||||
|
{"id": "hungry", "when": "status:Hungry", "hold": 2, "title": "Hungry",
|
||||||
|
"text": "Your belly's empty. Berries and mushrooms tide you over, but cooked meat and fish fill you up for longer."},
|
||||||
|
{"id": "diet", "when": "status:Poor diet|Malnourished", "hold": 2, "title": "Mix it up",
|
||||||
|
"text": "Eat the same thing every day and your body notices. Watch the three diet bars: meat, fish and greens. Keep them all topped up and you'll be stronger for it."},
|
||||||
|
{"id": "howl", "when": "event:howl", "title": "Wolves",
|
||||||
|
"text": "Hear that? Wolves have moved into the hills. They'll come hunting soon after dark. Walls, a door and a fire are your friends."},
|
||||||
|
{"id": "wolf_moon", "when": "wolf_moon", "title": "The red moon",
|
||||||
|
"text": "A red moon. The pack's bolder tonight and the big one leads them. Stay behind a good door, or stand by a fire with a weapon ready."},
|
||||||
|
{"id": "night", "when": "night", "hold": 5, "title": "Nightfall",
|
||||||
|
"text": "Dark and cold till morning. Keep near a fire or carry a torch. A bed under a roof lets you sleep through it."},
|
||||||
|
{"id": "hard_winter", "when": "hard_winter", "hold": 3, "title": "Hard winter",
|
||||||
|
"text": "A hard winter's come in: a few days of bitter cold, and blizzards that freeze you even by day. Stock wood and food, and stay close to home."},
|
||||||
|
{"id": "snow", "when": "weather:snow|blizzard", "hold": 3, "title": "Snow",
|
||||||
|
"text": "Snow's falling. It's colder out in it, and heavy snow piles up on roofs. Keep your fire fed."},
|
||||||
|
{"id": "storm", "when": "weather:storm", "hold": 3, "title": "Storm",
|
||||||
|
"text": "A storm. Lightning goes for the trees, so don't shelter under one. Your buildings are safe enough."},
|
||||||
|
{"id": "stamina", "when": "stamina_low", "title": "Out of breath",
|
||||||
|
"text": "You're spent. Stop swinging and running a moment and your stamina comes back. Rested by a fire, it comes back faster."},
|
||||||
|
{"id": "sheltered", "when": "status:Sheltered", "hold": 3, "title": "A proper shelter",
|
||||||
|
"text": "Roof and walls: the weather can't touch you here. Sit by a fire in here a little while and you'll be Rested."}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,8 +1,24 @@
|
|||||||
# Tutorial world design
|
# Tutorial world design
|
||||||
|
|
||||||
Status: **T1 and T2 built**: the whole tutorial, on the Valley island
|
Status: **T1, T2 and T4 built**: the whole tutorial, on the Valley island
|
||||||
(seed 1337), with Old Tam the hermit. T3 (its own island) and T4 (hints in
|
(seed 1337), with Old Tam the hermit, and his tips in normal worlds. T3
|
||||||
real worlds) are proposals.
|
(its own island) waits for the worldlets.
|
||||||
|
|
||||||
|
*As built in T4:*
|
||||||
|
- 16 tips in data/hints.json, in Old Tam's voice: soaked, cold, freezing,
|
||||||
|
hungry, a poor diet, food poisoning, food gone rotten, nightfall, the
|
||||||
|
wolves' howl, the red moon, a hard winter, snow, a storm, out of
|
||||||
|
breath, a first proper shelter, and back on your feet after dying.
|
||||||
|
- The `Hints` autoload (scripts/core/hints.gd) checks your own survival
|
||||||
|
statuses, the weather, the clock and your bag, plus two events (a howl,
|
||||||
|
a death), so tips work the same online (the server mirrors statuses).
|
||||||
|
Each shows once per player (user://hints.json), in list order, 16 s
|
||||||
|
apart, never while you're dead, and not in the tutorial world or on a
|
||||||
|
server.
|
||||||
|
- A card top right (scripts/ui/hint_card.gd) for 14 s; H puts it away.
|
||||||
|
Pause menu: **Old Tam's tips** on/off and **Show again**. On by default.
|
||||||
|
- tests/test_hints.gd. Headless runs never start tips unless a test asks
|
||||||
|
(so the suites can't use up your real ones).
|
||||||
|
|
||||||
*As built in T2:*
|
*As built in T2:*
|
||||||
- All five chapters, 22 steps (data/tutorial.json), in Old Tam's voice:
|
- All five chapters, 22 steps (data/tutorial.json), in Old Tam's voice:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ 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"
|
Guide="*res://scripts/core/guide.gd"
|
||||||
|
Hints="*res://scripts/core/hints.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"
|
||||||
|
|||||||
137
scripts/core/hints.gd
Normal file
137
scripts/core/hints.gd
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
extends Node
|
||||||
|
## T4 (docs/TUTORIAL_DESIGN.md §5): Old Tam's tips in normal worlds. Each tip
|
||||||
|
## (data/hints.json) shows once per player, the first time it fits: first
|
||||||
|
## rain, first night, the wolves' howl, a poor diet, a first death... They
|
||||||
|
## read only your own state (survival statuses, the weather, the clock, your
|
||||||
|
## bag) plus two events, so they work the same online. Not in the tutorial
|
||||||
|
## world (the tutorial covers it) or on a server. A setting turns them off.
|
||||||
|
## The HUD's hint card (scripts/ui/hint_card.gd) shows them.
|
||||||
|
|
||||||
|
signal shown(hint: Dictionary)
|
||||||
|
|
||||||
|
const GAP := 16.0 ## seconds between tips, so they don't pile up
|
||||||
|
const START_QUIET := 4.0 ## nothing in the first seconds of a world
|
||||||
|
|
||||||
|
var path := "user://hints.json" ## your settings: on or off, and the tips seen
|
||||||
|
var enabled := true
|
||||||
|
var seen: Array = []
|
||||||
|
var hints: Array = []
|
||||||
|
var guide_name := ""
|
||||||
|
var world: Node3D = null
|
||||||
|
var active := false
|
||||||
|
var _held: Dictionary = {} ## hint id -> seconds its condition has held
|
||||||
|
var _events: Dictionary = {} ## "howl" -> true, until the tip shows
|
||||||
|
var _cooldown := 0.0
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string("res://data/hints.json"))
|
||||||
|
if d is Dictionary:
|
||||||
|
hints = d.get("hints", [])
|
||||||
|
guide_name = String(d.get("guide", ""))
|
||||||
|
load_prefs()
|
||||||
|
|
||||||
|
func load_prefs() -> void:
|
||||||
|
enabled = true
|
||||||
|
seen = []
|
||||||
|
if not FileAccess.file_exists(path):
|
||||||
|
return
|
||||||
|
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
|
||||||
|
if d is Dictionary:
|
||||||
|
enabled = bool(d.get("enabled", true))
|
||||||
|
for id in d.get("seen", []):
|
||||||
|
seen.append(String(id))
|
||||||
|
|
||||||
|
func save_prefs() -> void:
|
||||||
|
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||||
|
if f != null:
|
||||||
|
f.store_string(JSON.stringify({"enabled": enabled, "seen": seen}, "\t"))
|
||||||
|
|
||||||
|
func set_enabled(on: bool) -> void:
|
||||||
|
enabled = on
|
||||||
|
save_prefs()
|
||||||
|
|
||||||
|
## Every tip can show again (the pause menu's "Show tips again").
|
||||||
|
func reset_seen() -> void:
|
||||||
|
seen = []
|
||||||
|
save_prefs()
|
||||||
|
|
||||||
|
var allow_headless := false ## tests: run with no screen (and their own `path`)
|
||||||
|
|
||||||
|
func start(w: Node3D) -> void:
|
||||||
|
if DisplayServer.get_name() == "headless" and not allow_headless:
|
||||||
|
return # (the other suites' worlds mustn't use up your real tips)
|
||||||
|
world = w
|
||||||
|
active = true
|
||||||
|
_held = {}
|
||||||
|
_events = {}
|
||||||
|
_cooldown = START_QUIET
|
||||||
|
|
||||||
|
func stop() -> void:
|
||||||
|
active = false
|
||||||
|
world = null
|
||||||
|
|
||||||
|
## Something that a condition can't read happened: "howl", "died".
|
||||||
|
func event(id: String) -> void:
|
||||||
|
if active:
|
||||||
|
_events[id] = true
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if active:
|
||||||
|
step(delta)
|
||||||
|
|
||||||
|
func step(dt: float) -> void:
|
||||||
|
if not active or not enabled or world == null or not is_instance_valid(world) or Guide.active:
|
||||||
|
return
|
||||||
|
_cooldown -= dt
|
||||||
|
var due: Dictionary = {}
|
||||||
|
for h in hints:
|
||||||
|
var id := String(h.id)
|
||||||
|
if seen.has(id):
|
||||||
|
continue
|
||||||
|
if _met(String(h.when)):
|
||||||
|
_held[id] = float(_held.get(id, 0.0)) + dt
|
||||||
|
if due.is_empty() and _held[id] >= float(h.get("hold", 1.0)):
|
||||||
|
due = h
|
||||||
|
else:
|
||||||
|
_held[id] = 0.0
|
||||||
|
if not due.is_empty() and _cooldown <= 0.0:
|
||||||
|
_show(due)
|
||||||
|
|
||||||
|
func _show(h: Dictionary) -> void:
|
||||||
|
seen.append(String(h.id))
|
||||||
|
_events.erase(String(h.when).trim_prefix("event:"))
|
||||||
|
save_prefs()
|
||||||
|
_cooldown = GAP
|
||||||
|
shown.emit(h)
|
||||||
|
|
||||||
|
func _met(when: String) -> bool:
|
||||||
|
var player: Node = world.player
|
||||||
|
if player == null or not is_instance_valid(player):
|
||||||
|
return false
|
||||||
|
var dead: bool = world.dead_waiting
|
||||||
|
var kind := when.get_slice(":", 0)
|
||||||
|
var vals := when.get_slice(":", 1).split("|", false) if when.contains(":") else PackedStringArray()
|
||||||
|
match kind:
|
||||||
|
"event":
|
||||||
|
return not dead and _events.has(vals[0])
|
||||||
|
"status":
|
||||||
|
if dead:
|
||||||
|
return false
|
||||||
|
for s in player.survival.statuses:
|
||||||
|
for v in vals:
|
||||||
|
if String(s).begins_with(v):
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
"weather":
|
||||||
|
return not dead and world.weather != null and String(world.weather.state) in vals
|
||||||
|
"has":
|
||||||
|
return not dead and player.inventory.count(vals[0]) > 0
|
||||||
|
"night":
|
||||||
|
return not dead and world.day_night.is_night()
|
||||||
|
"stamina_low":
|
||||||
|
return not dead and player.survival.stamina < 8.0
|
||||||
|
"wolf_moon":
|
||||||
|
return not dead and world.day_night.is_night() and world.day_night.wolf_moon_now()
|
||||||
|
"hard_winter":
|
||||||
|
return not dead and world.weather != null and world.weather.in_hard_winter()
|
||||||
|
return false
|
||||||
@@ -396,6 +396,7 @@ func howl() -> void:
|
|||||||
if world.server_mode:
|
if world.server_mode:
|
||||||
CreatureNet.howl((live[0] as Node3D).global_position + Vector3(0, 1, 0)) # everyone hears it
|
CreatureNet.howl((live[0] as Node3D).global_position + Vector3(0, 1, 0)) # everyone hears it
|
||||||
return
|
return
|
||||||
|
Hints.event("howl")
|
||||||
if _audio == null:
|
if _audio == null:
|
||||||
return
|
return
|
||||||
var from: Node3D = alpha if alpha_alive() else live[0] # (the alpha's howl is deeper)
|
var from: Node3D = alpha if alpha_alive() else live[0] # (the alpha's howl is deeper)
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ func _s2c_howl(at: Vector3) -> void:
|
|||||||
if not _alive_world():
|
if not _alive_world():
|
||||||
return
|
return
|
||||||
howls_heard += 1
|
howls_heard += 1
|
||||||
|
Hints.event("howl")
|
||||||
if DisplayServer.get_name() == "headless":
|
if DisplayServer.get_name() == "headless":
|
||||||
return
|
return
|
||||||
if _howl_player == null or not is_instance_valid(_howl_player):
|
if _howl_player == null or not is_instance_valid(_howl_player):
|
||||||
|
|||||||
63
scripts/ui/hint_card.gd
Normal file
63
scripts/ui/hint_card.gd
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
extends Control
|
||||||
|
## Old Tam's tips in normal worlds (scripts/core/hints.gd): a card top right,
|
||||||
|
## under the clock, for 14 seconds. H puts it away early.
|
||||||
|
|
||||||
|
const UITheme = preload("res://scripts/ui/theme.gd")
|
||||||
|
const SHOW_FOR := 14.0
|
||||||
|
|
||||||
|
var _box: PanelContainer
|
||||||
|
var _title: Label
|
||||||
|
var _text: Label
|
||||||
|
var _tween: Tween
|
||||||
|
|
||||||
|
func setup() -> void:
|
||||||
|
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
|
||||||
|
_box.modulate.a = 0.0
|
||||||
|
_box.visible = false
|
||||||
|
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)
|
||||||
|
v.add_child(UITheme.label("%s's tip (H hides · tips can be turned off in the menu)" % Hints.guide_name if not Hints.guide_name.is_empty() else "Tip (H hides)", 13, UITheme.TEXT_DIM))
|
||||||
|
_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)
|
||||||
|
Hints.shown.connect(show_hint)
|
||||||
|
|
||||||
|
func show_hint(h: Dictionary) -> void:
|
||||||
|
_title.text = String(h.title)
|
||||||
|
_text.text = ("\"%s\"" % String(h.text)) if not Hints.guide_name.is_empty() else String(h.text)
|
||||||
|
_box.visible = true
|
||||||
|
if _tween != null:
|
||||||
|
_tween.kill()
|
||||||
|
_tween = create_tween()
|
||||||
|
_tween.tween_property(_box, "modulate:a", 1.0, 0.4)
|
||||||
|
_tween.tween_interval(SHOW_FOR)
|
||||||
|
_tween.tween_property(_box, "modulate:a", 0.0, 0.8)
|
||||||
|
_tween.tween_callback(func(): _box.visible = false)
|
||||||
|
|
||||||
|
func is_showing() -> bool:
|
||||||
|
return _box.visible
|
||||||
|
|
||||||
|
func hide_hint() -> void:
|
||||||
|
if _tween != null:
|
||||||
|
_tween.kill()
|
||||||
|
_box.visible = false
|
||||||
|
_box.modulate.a = 0.0
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if _box.visible and event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_H:
|
||||||
|
hide_hint()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
@@ -179,6 +179,11 @@ func _build_stats(root: Control) -> void:
|
|||||||
guide.name = "Guide"
|
guide.name = "Guide"
|
||||||
root.add_child(guide)
|
root.add_child(guide)
|
||||||
guide.setup(player)
|
guide.setup(player)
|
||||||
|
# Old Tam's tips in normal worlds (T4), in the same place.
|
||||||
|
var tips := preload("res://scripts/ui/hint_card.gd").new()
|
||||||
|
tips.name = "HintCard"
|
||||||
|
root.add_child(tips)
|
||||||
|
tips.setup()
|
||||||
|
|
||||||
func _process(_d: float) -> void:
|
func _process(_d: float) -> void:
|
||||||
_fade_chat()
|
_fade_chat()
|
||||||
|
|||||||
@@ -80,6 +80,26 @@ func bind(p: CharacterBody3D) -> void:
|
|||||||
sens_row.add_child(sens)
|
sens_row.add_child(sens)
|
||||||
_main.add_child(sens_row)
|
_main.add_child(sens_row)
|
||||||
|
|
||||||
|
# Old Tam's tips in normal worlds (T4): on or off, or all of them again.
|
||||||
|
if not Guide.active:
|
||||||
|
var tips_row := HBoxContainer.new()
|
||||||
|
var tips := CheckButton.new()
|
||||||
|
tips.text = "Old Tam's tips"
|
||||||
|
tips.focus_mode = Control.FOCUS_NONE
|
||||||
|
tips.button_pressed = Hints.enabled
|
||||||
|
tips.add_theme_font_size_override("font_size", 18)
|
||||||
|
tips.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
tips.toggled.connect(Hints.set_enabled)
|
||||||
|
tips_row.add_child(tips)
|
||||||
|
var again := Button.new()
|
||||||
|
again.text = "Show again"
|
||||||
|
again.focus_mode = Control.FOCUS_NONE
|
||||||
|
again.add_theme_font_size_override("font_size", 15)
|
||||||
|
again.tooltip_text = "Every tip shows once more, when it next fits"
|
||||||
|
again.pressed.connect(func(): Hints.reset_seen(); again.text = "Done")
|
||||||
|
tips_row.add_child(again)
|
||||||
|
_main.add_child(tips_row)
|
||||||
|
|
||||||
# The tutorial: skip a step, or leave it (saved, to carry on later).
|
# The tutorial: skip a step, or leave it (saved, to carry on later).
|
||||||
if Guide.active:
|
if Guide.active:
|
||||||
_main.add_child(_button("Skip this tutorial step", func(): Guide.skip(); close()))
|
_main.add_child(_button("Skip this tutorial step", func(): Guide.skip(); close()))
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ func heat_at(pos: Vector3, holder: Node = null) -> float:
|
|||||||
## Death: everything you carried goes into a grave where you fell, and you
|
## Death: everything you carried goes into a grave where you fell, and you
|
||||||
## wake up at the island start. The world carries on.
|
## wake up at the island start. The world carries on.
|
||||||
func _on_player_died() -> void:
|
func _on_player_died() -> void:
|
||||||
|
Hints.event("died")
|
||||||
var pos := player.global_position
|
var pos := player.global_position
|
||||||
pos.y = terrain.sample_height(pos.x, pos.z)
|
pos.y = terrain.sample_height(pos.x, pos.z)
|
||||||
if pos.y < terrain.water_level:
|
if pos.y < terrain.water_level:
|
||||||
@@ -245,6 +246,7 @@ func respawn_after_death() -> void:
|
|||||||
|
|
||||||
## Online: the server says we died. Same screen; Respawn asks the server.
|
## Online: the server says we died. Same screen; Respawn asks the server.
|
||||||
func died_online(text: String, has_items: bool, to_bed: bool, penalty: String = "grave") -> void:
|
func died_online(text: String, has_items: bool, to_bed: bool, penalty: String = "grave") -> void:
|
||||||
|
Hints.event("died")
|
||||||
dead_waiting = true
|
dead_waiting = true
|
||||||
player.velocity = Vector3.ZERO
|
player.velocity = Vector3.ZERO
|
||||||
player.set_physics_process(false)
|
player.set_physics_process(false)
|
||||||
@@ -404,6 +406,10 @@ func _on_terrain_ready() -> void:
|
|||||||
player.set_physics_process(true)
|
player.set_physics_process(true)
|
||||||
set_physics_process(true)
|
set_physics_process(true)
|
||||||
creatures.setup(self)
|
creatures.setup(self)
|
||||||
|
# Old Tam's tips (T4): your own world or a server's, not the tutorial's.
|
||||||
|
if not server_mode and not menu_mode and not bool(Saves.read_meta(Saves.current_id).get("tutorial", false)) \
|
||||||
|
and not "--tutorial" in OS.get_cmdline_user_args():
|
||||||
|
Hints.start(self)
|
||||||
if is_online():
|
if is_online():
|
||||||
_go_online()
|
_go_online()
|
||||||
_maybe_capture()
|
_maybe_capture()
|
||||||
@@ -553,6 +559,8 @@ func _exit_tree() -> void:
|
|||||||
save_now()
|
save_now()
|
||||||
if Guide.world == self:
|
if Guide.world == self:
|
||||||
Guide.stop()
|
Guide.stop()
|
||||||
|
if Hints.world == self:
|
||||||
|
Hints.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:
|
||||||
|
|||||||
@@ -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_tutorial test_tutorial_full 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_tutorial_full test_hints 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,
|
||||||
|
|||||||
168
tests/test_hints.gd
Normal file
168
tests/test_hints.gd
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## T4 (docs/TUTORIAL_DESIGN.md §5): Old Tam's tips in a normal world. Each
|
||||||
|
## fits its moment, shows once, waits its turn, stays put while you're dead,
|
||||||
|
## is remembered across sessions, can be switched off or shown again, and
|
||||||
|
## never runs in the tutorial world.
|
||||||
|
## Run: godot --headless --path . --script res://tests/test_hints.gd
|
||||||
|
|
||||||
|
var _fails := 0
|
||||||
|
var _finished := false
|
||||||
|
var H: Node
|
||||||
|
var S: Node
|
||||||
|
var world: Node3D
|
||||||
|
var player: CharacterBody3D
|
||||||
|
var sv: Node
|
||||||
|
var got: Array = []
|
||||||
|
var card: Control
|
||||||
|
|
||||||
|
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:
|
||||||
|
H = root.get_node("Hints")
|
||||||
|
S = root.get_node("Saves")
|
||||||
|
H.path = "user://test_hints.json"
|
||||||
|
H.allow_headless = true
|
||||||
|
DirAccess.remove_absolute(H.path)
|
||||||
|
H.load_prefs()
|
||||||
|
H.shown.connect(func(h): got.append(String(h.id)))
|
||||||
|
S.root_dir = "user://test_worlds"
|
||||||
|
for w in S.list_worlds():
|
||||||
|
S.delete_world(w.id)
|
||||||
|
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)
|
||||||
|
DirAccess.remove_absolute(H.path)
|
||||||
|
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
|
||||||
|
quit(1 if _fails > 0 else 0)
|
||||||
|
|
||||||
|
func _load(tutorial: bool) -> void:
|
||||||
|
if world != null:
|
||||||
|
world.queue_free()
|
||||||
|
await process_frame
|
||||||
|
S.current_id = S.create_world("Tips" if not tutorial else "Tutorial", 1337, {"tutorial": tutorial})
|
||||||
|
world = load("res://scenes/world.tscn").instantiate()
|
||||||
|
root.add_child(world)
|
||||||
|
for _i in range(5):
|
||||||
|
await physics_frame
|
||||||
|
player = world.player
|
||||||
|
player.set_physics_process(false)
|
||||||
|
sv = player.survival
|
||||||
|
sv.set_process(false)
|
||||||
|
world.day_night.frozen = true
|
||||||
|
world.day_night.time = 10.0 / 24.0
|
||||||
|
world.day_night.apply(true)
|
||||||
|
world.weather.set_process(false)
|
||||||
|
world.weather.force("clear")
|
||||||
|
if world.wolves != null:
|
||||||
|
world.wolves.set_physics_process(false)
|
||||||
|
world.creatures.enabled = false
|
||||||
|
|
||||||
|
## Runs the tips for `secs` (as frames of 0.5 s), with your statuses fresh.
|
||||||
|
func _for(secs: float) -> void:
|
||||||
|
var t := 0.0
|
||||||
|
while t < secs:
|
||||||
|
sv._update_statuses()
|
||||||
|
H.step(0.5)
|
||||||
|
t += 0.5
|
||||||
|
|
||||||
|
func _body() -> void:
|
||||||
|
print("the tips")
|
||||||
|
var kinds := ["event", "status", "weather", "has", "night", "stamina_low", "wolf_moon", "hard_winter"]
|
||||||
|
var bad: Array = []
|
||||||
|
for h in H.hints:
|
||||||
|
if not String(h.when).get_slice(":", 0) in kinds or String(h.title).is_empty() or String(h.text).is_empty():
|
||||||
|
bad.append(h.id)
|
||||||
|
check(H.hints.size() >= 12 and bad.is_empty(), "%d tips, each with a title, text and a condition it knows %s" % [H.hints.size(), bad])
|
||||||
|
check(H.enabled and H.seen.is_empty(), "on by default, none seen yet")
|
||||||
|
|
||||||
|
print("a normal world")
|
||||||
|
await _load(false)
|
||||||
|
check(H.active and H.world == world, "they run in your own world")
|
||||||
|
card = load("res://scripts/ui/hint_card.gd").new()
|
||||||
|
root.add_child(card)
|
||||||
|
card.setup()
|
||||||
|
await _for(10.0)
|
||||||
|
check(got.is_empty(), "a calm, dry, fed morning: nothing to say")
|
||||||
|
|
||||||
|
sv.wet = 60.0
|
||||||
|
await _for(3.0)
|
||||||
|
check(got == ["wet"], "soaked: the wet tip")
|
||||||
|
check(card.is_showing() and card._title.text == "Soaked" and card._text.text.begins_with("\"Wet clothes"), "the card shows it, in Old Tam's words")
|
||||||
|
sv.wet = 0.0
|
||||||
|
|
||||||
|
sv.hunger = 10.0
|
||||||
|
await _for(6.0)
|
||||||
|
check(got.size() == 1, "hungry straight after: it waits its turn")
|
||||||
|
await _for(14.0)
|
||||||
|
check(got.back() == "hungry", "then the hungry tip")
|
||||||
|
sv.hunger = 90.0
|
||||||
|
|
||||||
|
sv.wet = 60.0
|
||||||
|
await _for(20.0)
|
||||||
|
check(got.count("wet") == 1, "soaked again: once is enough")
|
||||||
|
sv.wet = 0.0
|
||||||
|
|
||||||
|
print("the setting")
|
||||||
|
H.set_enabled(false)
|
||||||
|
world.day_night.time = 23.0 / 24.0
|
||||||
|
world.day_night.apply(true)
|
||||||
|
await _for(25.0)
|
||||||
|
check(got.size() == 2, "tips off: nightfall goes by without one")
|
||||||
|
var f: Variant = JSON.parse_string(FileAccess.get_file_as_string(H.path))
|
||||||
|
check(f is Dictionary and f.enabled == false and f.seen == ["wet", "hungry"], "the setting and the seen tips are saved")
|
||||||
|
H.set_enabled(true)
|
||||||
|
await _for(6.0)
|
||||||
|
check(got.back() == "night", "tips on: the night tip")
|
||||||
|
world.day_night.time = 10.0 / 24.0
|
||||||
|
world.day_night.apply(true)
|
||||||
|
|
||||||
|
print("events")
|
||||||
|
H.event("howl")
|
||||||
|
await _for(17.0)
|
||||||
|
check(got.back() == "howl", "the wolves howl: the wolf tip")
|
||||||
|
world.dead_waiting = true
|
||||||
|
H.event("died")
|
||||||
|
await _for(25.0)
|
||||||
|
check(got.back() == "howl", "dead, on the death screen: no tip yet")
|
||||||
|
world.dead_waiting = false
|
||||||
|
await _for(1.0)
|
||||||
|
check(got.back() == "died", "back on your feet: the death tip")
|
||||||
|
player.inventory.add("rotten_food", 1)
|
||||||
|
await _for(17.0)
|
||||||
|
check(got.back() == "rotten", "something rots in your bag: the food tip")
|
||||||
|
player.inventory.remove("rotten_food", 1)
|
||||||
|
|
||||||
|
print("next time")
|
||||||
|
H.load_prefs()
|
||||||
|
check(H.seen.size() == 6 and H.seen.has("died"), "the seen tips come back from the file (%d)" % H.seen.size())
|
||||||
|
await _load(false)
|
||||||
|
got.clear()
|
||||||
|
sv.wet = 60.0
|
||||||
|
sv.hunger = 10.0
|
||||||
|
await _for(25.0)
|
||||||
|
check(got.is_empty(), "a new session: wet and hungry again, but they've been said")
|
||||||
|
H.reset_seen()
|
||||||
|
await _for(5.0)
|
||||||
|
check(got == ["wet"], "Show again: the wet tip comes back")
|
||||||
|
sv.wet = 0.0
|
||||||
|
sv.hunger = 90.0
|
||||||
|
|
||||||
|
print("not in the tutorial")
|
||||||
|
await _load(true)
|
||||||
|
check(not H.active, "the tutorial world has its own guide: no tips")
|
||||||
|
got.clear()
|
||||||
|
sv.wet = 60.0
|
||||||
|
await _for(10.0)
|
||||||
|
check(got.is_empty(), "(soaked there: nothing)")
|
||||||
|
card.queue_free()
|
||||||
|
world.queue_free()
|
||||||
|
await process_frame
|
||||||
|
_finished = true
|
||||||
Reference in New Issue
Block a user