269 lines
9.2 KiB
GDScript
269 lines
9.2 KiB
GDScript
extends Node
|
|
## The music and the ambience, chosen from what's going on around you. The
|
|
## tracks are synthesised by tools/audio/make_audio.py (assets/audio/music):
|
|
##
|
|
## boss the Old Stag is up, within 70 m
|
|
## danger a wolf, bear, boar or barrow guard is coming for someone nearby
|
|
## (and for 10 s after it stops)
|
|
## storm a storm or blizzard, or the red wolf moon
|
|
## night after dark
|
|
## calm by day (and on the title screen)
|
|
##
|
|
## Calm, night and storm play once and then rest for a while, with just the
|
|
## ambience (birds by day, crickets at night), so the music doesn't wear
|
|
## thin; danger and boss loop until it's over. Changes crossfade. Indoors the
|
|
## music and ambience are quieter. It only reads things every game already
|
|
## has (the clock, the weather, the creatures it can see), so it works the
|
|
## same online. Headless, it still works out the mood (`mood`) but plays
|
|
## nothing.
|
|
|
|
const DIR := "res://assets/audio/music/"
|
|
const AMB := "res://assets/audio/ambience/"
|
|
const MOODS := ["boss", "danger", "storm", "night", "calm"]
|
|
const LOOPS := ["danger", "boss"]
|
|
## States a creature is in when it's after someone.
|
|
const AGGRO := ["charge", "stalk", "lunge", "bash", "stomp", "telegraph", "warn", "raid", "summon", "alert", "defend"]
|
|
const DANGER_RANGE := 26.0
|
|
const BOSS_RANGE := 70.0
|
|
const DANGER_HOLD := 10.0
|
|
const FADE := 3.0
|
|
const FADE_FAST := 1.2
|
|
const REST := [35.0, 80.0] ## seconds of quiet between calm/night/storm plays
|
|
|
|
var world: Node3D = null
|
|
var mood := "" ## what the music is for now
|
|
var playing := "" ## the track that's sounding ("" between plays)
|
|
var _danger_until := -1.0
|
|
var _clock := 0.0
|
|
var _tick := 0.0
|
|
var _rest_left := 0.0
|
|
var _a: AudioStreamPlayer
|
|
var _b: AudioStreamPlayer
|
|
var _cur: AudioStreamPlayer
|
|
var _birds: AudioStreamPlayer
|
|
var _crickets: AudioStreamPlayer
|
|
var _owl: AudioStreamPlayer3D
|
|
var _owl_left := 20.0
|
|
var _owl_calls: Array = [] ## a few different calls, so it doesn't repeat itself
|
|
var owls := 0 ## hoots so far (tests)
|
|
const OWL_EVERY := [35.0, 110.0]
|
|
var _tracks: Dictionary = {}
|
|
var _rng := RandomNumberGenerator.new()
|
|
var _quiet := false
|
|
var _fading := 0 ## crossfades in progress
|
|
var duck := false ## Old Tam is talking (narrator.gd): the music dips
|
|
|
|
func _ready() -> void:
|
|
process_mode = Node.PROCESS_MODE_ALWAYS # (the pause menu stops the game, not the music)
|
|
_rng.randomize()
|
|
_quiet = DisplayServer.get_name() == "headless"
|
|
if _quiet:
|
|
return
|
|
_a = _player("Music")
|
|
_b = _player("Music")
|
|
_cur = _a
|
|
_birds = _player("Ambience", _load(AMB + "birds.ogg", true))
|
|
_crickets = _player("Ambience", _load(AMB + "crickets.ogg", true))
|
|
_owl = AudioStreamPlayer3D.new()
|
|
_owl.bus = "Ambience"
|
|
for i in range(2):
|
|
var call: Variant = _load(AMB + "owl_%d.ogg" % i, false)
|
|
if call is AudioStream:
|
|
_owl_calls.append(call)
|
|
_owl.unit_size = 30.0 # (far off in the trees, but clearly heard)
|
|
_owl.max_distance = 120.0
|
|
add_child(_owl)
|
|
for p in [_a, _b]:
|
|
p.finished.connect(_on_finished.bind(p))
|
|
|
|
func _player(bus: String, stream: AudioStream = null) -> AudioStreamPlayer:
|
|
var p := AudioStreamPlayer.new()
|
|
p.bus = bus
|
|
p.volume_db = -80.0
|
|
p.stream = stream
|
|
add_child(p)
|
|
return p
|
|
|
|
static func _load(path: String, loop: bool) -> AudioStream:
|
|
var s: Variant = load(path)
|
|
if s is AudioStreamOggVorbis:
|
|
(s as AudioStreamOggVorbis).loop = loop
|
|
return s
|
|
|
|
func start(w: Node3D) -> void:
|
|
world = w
|
|
mood = ""
|
|
_danger_until = -1.0
|
|
_tick = 0.0
|
|
if not _quiet:
|
|
for p in [_birds, _crickets]:
|
|
if not p.playing:
|
|
p.play()
|
|
|
|
func stop(w: Node3D = null) -> void:
|
|
if w != null and w != world:
|
|
return
|
|
world = null
|
|
|
|
func _process(delta: float) -> void:
|
|
step(delta)
|
|
|
|
func step(dt: float) -> void:
|
|
_clock += dt
|
|
_tick -= dt
|
|
if _tick <= 0.0:
|
|
_tick = 0.5
|
|
var m := decide()
|
|
if m != mood:
|
|
_change(m)
|
|
if not _quiet and playing.is_empty() and not mood.is_empty():
|
|
_rest_left -= dt
|
|
if _rest_left <= 0.0:
|
|
_start_track(mood, FADE)
|
|
_ambience(dt)
|
|
|
|
## What the music should be for, right now.
|
|
func decide() -> String:
|
|
if world == null or not is_instance_valid(world):
|
|
return "calm"
|
|
if world.get("menu_mode"):
|
|
return "calm"
|
|
var me: Node3D = world.get("player")
|
|
if me == null or not is_instance_valid(me):
|
|
return "calm"
|
|
var here := me.global_position
|
|
var boss := false
|
|
var danger := false
|
|
for c in get_tree().get_nodes_in_group("creature"):
|
|
if not is_instance_valid(c) or not (c is Node3D) or (c.has_method("is_dead") and c.is_dead()):
|
|
continue
|
|
var d: float = (c as Node3D).global_position.distance_to(here)
|
|
var kind := String(c.get("kind")) if c.get("kind") != null else ""
|
|
if kind == "old_stag" and d < BOSS_RANGE:
|
|
boss = true
|
|
elif d < DANGER_RANGE and String(c.get("state")) in AGGRO and _hostile(c):
|
|
danger = true
|
|
if boss:
|
|
return "boss"
|
|
if danger:
|
|
_danger_until = _clock + DANGER_HOLD
|
|
if _clock < _danger_until:
|
|
return "danger"
|
|
var dn: Node = world.get("day_night")
|
|
var wx: Node = world.get("weather")
|
|
var night: bool = dn != null and dn.is_night()
|
|
if wx != null and String(wx.get("state")) in ["storm", "blizzard"]:
|
|
return "storm"
|
|
if night and dn.wolf_moon_now():
|
|
return "storm"
|
|
return "night" if night else "calm"
|
|
|
|
static func _hostile(c: Node) -> bool:
|
|
var def: Variant = c.get("def")
|
|
return def is Dictionary and (def as Dictionary).has("damage")
|
|
|
|
func _change(m: String) -> void:
|
|
var was := mood
|
|
mood = m
|
|
if _quiet:
|
|
return
|
|
# Into a fight: straight in. Out of one: let the fight music fade, then a
|
|
# short rest before the calmer music comes back.
|
|
if m in LOOPS:
|
|
_start_track(m, FADE_FAST)
|
|
elif was in LOOPS or was.is_empty():
|
|
_fade_out(FADE)
|
|
_rest_left = 4.0 if not was.is_empty() else 1.5
|
|
elif not playing.is_empty():
|
|
_start_track(m, FADE) # (a calm piece mid-play crossfades to the night one)
|
|
else:
|
|
_rest_left = minf(_rest_left, 6.0)
|
|
|
|
func _start_track(m: String, fade: float) -> void:
|
|
if not _tracks.has(m):
|
|
_tracks[m] = _load(DIR + m + ".ogg", m in LOOPS)
|
|
var next: AudioStreamPlayer = _b if _cur == _a else _a
|
|
var old := _cur
|
|
next.stream = _tracks[m]
|
|
next.volume_db = -40.0
|
|
next.play()
|
|
_fade(next, _target_db(), fade)
|
|
if old.playing:
|
|
_fade(old, -60.0, fade, true)
|
|
_cur = next
|
|
playing = m
|
|
|
|
func _fade_out(fade: float) -> void:
|
|
if _cur != null and _cur.playing:
|
|
_fade(_cur, -60.0, fade, true)
|
|
playing = ""
|
|
|
|
func _fade(p: AudioStreamPlayer, db: float, secs: float, then_stop: bool = false) -> void:
|
|
_fading += 1
|
|
var tw := create_tween()
|
|
tw.tween_property(p, "volume_db", db, secs)
|
|
tw.tween_callback(func(): _fading -= 1)
|
|
if then_stop:
|
|
tw.tween_callback(p.stop)
|
|
|
|
func _on_finished(p: AudioStreamPlayer) -> void:
|
|
if p == _cur:
|
|
playing = ""
|
|
_rest_left = _rng.randf_range(REST[0], REST[1])
|
|
|
|
## Indoors everything's a little further away.
|
|
func _indoors() -> bool:
|
|
if world == null or not is_instance_valid(world) or world.get("player") == null:
|
|
return false
|
|
var sv: Node = world.player.get("survival")
|
|
return sv != null and bool(sv.get("sheltered"))
|
|
|
|
func _target_db() -> float:
|
|
return (-6.0 if _indoors() else -2.0) - (7.0 if duck else 0.0)
|
|
|
|
func _ambience(dt: float) -> void:
|
|
if _quiet or _birds == null:
|
|
return
|
|
var day := 1.0
|
|
var wet := 0.0
|
|
var snow := false
|
|
if world != null and is_instance_valid(world) and world.get("day_night") != null:
|
|
day = float(world.day_night.daylight)
|
|
var s := String(world.weather.get("state")) if world.get("weather") != null else "clear"
|
|
wet = 1.0 if s in ["rain", "storm", "blizzard"] else 0.0
|
|
snow = s in ["snow", "blizzard"]
|
|
var inside := 0.35 if _indoors() else 1.0
|
|
if world == null or not is_instance_valid(world) or world.get("menu_mode"):
|
|
inside = 0.0 # (the title screen: music only, no birds or crickets)
|
|
var birds := clampf((day - 0.35) / 0.4, 0.0, 1.0) * (1.0 - wet) * inside
|
|
var crickets := clampf((0.45 - day) / 0.3, 0.0, 1.0) * (0.0 if snow else 1.0) * (1.0 - wet * 0.8) * inside
|
|
_owl_step(dt, crickets > 0.5, inside)
|
|
var k := clampf(dt * 0.8, 0.0, 1.0)
|
|
_birds.volume_db = lerpf(_birds.volume_db, linear_to_db(maxf(birds * 0.28, 0.0001)), k) # (in the background: the recording is full-scale)
|
|
_crickets.volume_db = lerpf(_crickets.volume_db, linear_to_db(maxf(crickets * 0.4, 0.0001)), k) # (a recording, full-scale)
|
|
if _cur != null and _cur.playing and _fading == 0:
|
|
_cur.volume_db = lerpf(_cur.volume_db, _target_db(), k)
|
|
|
|
## Now and then at night (when the crickets are out: dark, dry, no snow), an
|
|
## owl hoots from somewhere 25-45 m off in a random direction. Only you hear
|
|
## your owl; online, everyone's night has its own.
|
|
func _owl_step(dt: float, night_out: bool, inside: float) -> void:
|
|
if _owl == null or _owl_calls.is_empty() or not night_out:
|
|
_owl_left = maxf(_owl_left, 8.0)
|
|
return
|
|
_owl_left -= dt
|
|
if _owl_left > 0.0 or _owl.playing:
|
|
return
|
|
_owl_left = _rng.randf_range(OWL_EVERY[0], OWL_EVERY[1])
|
|
var me: Node3D = world.get("player") if world != null else null
|
|
if me == null:
|
|
return
|
|
var a := _rng.randf() * TAU
|
|
var r := _rng.randf_range(25.0, 45.0)
|
|
_owl.global_position = me.global_position + Vector3(cos(a) * r, _rng.randf_range(6.0, 12.0), sin(a) * r)
|
|
_owl.stream = _owl_calls[_rng.randi() % _owl_calls.size()]
|
|
_owl.pitch_scale = _rng.randf_range(0.96, 1.04)
|
|
_owl.volume_db = -8.0 + linear_to_db(maxf(inside, 0.05))
|
|
_owl.play()
|
|
owls += 1
|