T4: Old Tam's tips in normal worlds, with a setting
This commit is contained in:
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:
|
||||
CreatureNet.howl((live[0] as Node3D).global_position + Vector3(0, 1, 0)) # everyone hears it
|
||||
return
|
||||
Hints.event("howl")
|
||||
if _audio == null:
|
||||
return
|
||||
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():
|
||||
return
|
||||
howls_heard += 1
|
||||
Hints.event("howl")
|
||||
if DisplayServer.get_name() == "headless":
|
||||
return
|
||||
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"
|
||||
root.add_child(guide)
|
||||
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:
|
||||
_fade_chat()
|
||||
|
||||
@@ -80,6 +80,26 @@ func bind(p: CharacterBody3D) -> void:
|
||||
sens_row.add_child(sens)
|
||||
_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).
|
||||
if Guide.active:
|
||||
_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
|
||||
## wake up at the island start. The world carries on.
|
||||
func _on_player_died() -> void:
|
||||
Hints.event("died")
|
||||
var pos := player.global_position
|
||||
pos.y = terrain.sample_height(pos.x, pos.z)
|
||||
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.
|
||||
func died_online(text: String, has_items: bool, to_bed: bool, penalty: String = "grave") -> void:
|
||||
Hints.event("died")
|
||||
dead_waiting = true
|
||||
player.velocity = Vector3.ZERO
|
||||
player.set_physics_process(false)
|
||||
@@ -404,6 +406,10 @@ func _on_terrain_ready() -> void:
|
||||
player.set_physics_process(true)
|
||||
set_physics_process(true)
|
||||
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():
|
||||
_go_online()
|
||||
_maybe_capture()
|
||||
@@ -553,6 +559,8 @@ func _exit_tree() -> void:
|
||||
save_now()
|
||||
if Guide.world == self:
|
||||
Guide.stop()
|
||||
if Hints.world == self:
|
||||
Hints.stop()
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what != NOTIFICATION_WM_CLOSE_REQUEST or menu_mode:
|
||||
|
||||
Reference in New Issue
Block a user