Audio: mood music, ambience, sound effects and creature voices (Kenney CC0 + synthesised); protocol 13
This commit is contained in:
@@ -17,6 +17,7 @@ var _noise := FastNoiseLite.new()
|
||||
var _lit := true
|
||||
var _roofed_cache := -1.0 ## -1 unknown, 0 open sky, 1 under a roof
|
||||
var _roof_timer := 0.0
|
||||
var _crackle: AudioStreamPlayer3D
|
||||
|
||||
func _build() -> void:
|
||||
add_to_group("station:campfire")
|
||||
@@ -38,6 +39,19 @@ func _build() -> void:
|
||||
[0.0, Color(1.0, 0.75, 0.35, 1.0)], [1.0, Color(1.0, 0.3, 0.05, 0.0)]]), true, 0.04, 0.15))
|
||||
for p in _fx:
|
||||
add_child(p)
|
||||
# The crackle, close to it (sfx.gd's buses; not on a headless server).
|
||||
if DisplayServer.get_name() != "headless":
|
||||
_crackle = AudioStreamPlayer3D.new()
|
||||
var st: Variant = load("res://assets/audio/ambience/fire.ogg")
|
||||
if st is AudioStreamOggVorbis:
|
||||
(st as AudioStreamOggVorbis).loop = true
|
||||
_crackle.stream = st
|
||||
_crackle.bus = "Ambience"
|
||||
_crackle.unit_size = 2.0
|
||||
_crackle.max_distance = 16.0
|
||||
_crackle.volume_db = -4.0
|
||||
_crackle.position = Vector3(0, 0.3, 0)
|
||||
add_child(_crackle)
|
||||
# TODO: smoke. Alpha-blended unshaded particles rendered as an opaque dark
|
||||
# ball whatever their ramp colour (additive flames are fine). Needs a small
|
||||
# particle shader.
|
||||
@@ -118,6 +132,12 @@ func _set_lit(on: bool) -> void:
|
||||
_light.visible = on
|
||||
for p in _fx:
|
||||
p.emitting = on
|
||||
if _crackle != null:
|
||||
_crackle.autoplay = on # (lit before it's in the tree: it starts when it's added)
|
||||
if on and not _crackle.playing and _crackle.is_inside_tree():
|
||||
_crackle.play(randf() * 5.0)
|
||||
elif not on:
|
||||
_crackle.stop()
|
||||
|
||||
func save_extra() -> Dictionary:
|
||||
return {"fuel": fuel}
|
||||
|
||||
@@ -219,6 +219,7 @@ func craft(player: Node, recipe_id: String) -> bool:
|
||||
inv.remove(id, int(r.inputs[id]))
|
||||
_give(player, r.output, r.count)
|
||||
crafted.emit(player, recipe_id, String(r.output), int(r.count))
|
||||
Sfx.at("craft", player.global_position)
|
||||
return true
|
||||
|
||||
func pickup(player: Node3D, pickup_node: Node3D) -> bool:
|
||||
@@ -239,6 +240,7 @@ func pickup(player: Node3D, pickup_node: Node3D) -> bool:
|
||||
notify.emit("+%d %s" % [pickup_node.amount, ItemDB.item_name(id)])
|
||||
elif _give(player, id, pickup_node.amount) <= 0:
|
||||
return false
|
||||
Sfx.at("pickup", pickup_node.global_position)
|
||||
pickup_node.collected()
|
||||
return true
|
||||
|
||||
@@ -262,6 +264,7 @@ func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool:
|
||||
for id in drops:
|
||||
_give(player, id, int(drops[id]))
|
||||
use_held(player)
|
||||
Sfx.at(harvest_sound(String(node.tool_kind), bool(node.depleted)), hit_point)
|
||||
harvested.emit(player, node, bool(node.depleted))
|
||||
return true
|
||||
|
||||
@@ -285,6 +288,7 @@ func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool:
|
||||
return false
|
||||
player.inventory.take_from(slot, 1)
|
||||
var piece: Node3D = world.spawn_piece(def, xf)
|
||||
Sfx.at(place_sound(id), xf.origin)
|
||||
placed.emit(player, id, piece)
|
||||
return true
|
||||
|
||||
@@ -308,6 +312,7 @@ func dismantle(player: Node3D, piece: Node3D) -> bool:
|
||||
notify.emit("Inventory full")
|
||||
return false
|
||||
_give(player, piece.item_id, 1)
|
||||
Sfx.at("dismantle", piece.global_position)
|
||||
piece.get_parent().get_parent().remove_piece(piece, player)
|
||||
return true
|
||||
|
||||
@@ -317,6 +322,7 @@ func toggle_door(player: Node3D, door: Node3D) -> bool:
|
||||
if _online():
|
||||
return _ask("door", [WorldNet.nid(door)])
|
||||
door.set_open(not door.open)
|
||||
Sfx.at("door_open" if door.open else "door_close", door.global_position)
|
||||
return true
|
||||
|
||||
## E on the barrow's coffin (barrow.gd): take what's in it. Opening it wakes
|
||||
@@ -377,6 +383,7 @@ func use_bed(player: Node3D, bed: Node3D) -> bool:
|
||||
else:
|
||||
world.set_bed_spawn(bed)
|
||||
notify.emit("Spawn point set")
|
||||
Sfx.for_player(player, "ui_good")
|
||||
return true
|
||||
var why: String = world.sleep_block_reason(bed)
|
||||
if not why.is_empty():
|
||||
@@ -467,6 +474,7 @@ func hit_player(player: Node3D, peer: int) -> bool:
|
||||
return false
|
||||
player.melee_ready_at = Time.get_ticks_msec() + int(m.cooldown * 1000.0 * 0.8)
|
||||
target.survival.damage(m.damage, player.global_position, String(player.display))
|
||||
Sfx.at("hit_flesh", target.global_position)
|
||||
use_held(player)
|
||||
notify.emit("Hit %s" % String(target.display))
|
||||
return true
|
||||
@@ -499,6 +507,7 @@ func throw_spear(player: Node3D, slot: int, origin: Vector3, dir: Vector3, world
|
||||
var id: String = s.id
|
||||
var left := ItemDB.durability(s) - 1
|
||||
player.inventory.take_from(slot, 1)
|
||||
Sfx.at("throw", origin)
|
||||
var pr: Node3D = world.launch(id, origin, dir.normalized() * float(w.throw.speed) + player.velocity * 0.5,
|
||||
float(w.throw.damage), player)
|
||||
if ItemDB.max_durability(id) > 0:
|
||||
@@ -545,6 +554,7 @@ func shoot(player: Node3D, origin: Vector3, dir: Vector3, power: float, world: N
|
||||
var dmg: float = lerpf(float(bow.damage[0]), float(bow.damage[1]), power)
|
||||
dmg *= float(ItemDB.get_item(ammo).get("ammo", {}).get("damage_mult", 1.0))
|
||||
world.launch(ammo, origin, dir.normalized() * spd, dmg, player)
|
||||
Sfx.at("bow", origin)
|
||||
use_held(player)
|
||||
return true
|
||||
|
||||
@@ -558,6 +568,7 @@ func butcher(player: Node3D, carcass: Node3D) -> bool:
|
||||
var drops: Dictionary = carcass.butcher_drops()
|
||||
carcass.remove_from_group("carcass")
|
||||
butchered.emit(player, String(carcass.kind))
|
||||
Sfx.at("butcher", carcass.global_position)
|
||||
for id in drops:
|
||||
var got := _give(player, id, int(drops[id]))
|
||||
if got < int(drops[id]):
|
||||
@@ -583,6 +594,7 @@ func cast_line(player: Node3D, origin: Vector3, dir: Vector3, world: Node3D) ->
|
||||
var d := dir.normalized()
|
||||
d.y = maxf(d.y, -0.2) + 0.35 # a lobbed cast, even when looking level
|
||||
world.start_fishing(player, origin, d.normalized() * float(rod.get("cast_speed", 12.0)))
|
||||
Sfx.at("cast", origin)
|
||||
use_held(player)
|
||||
return true
|
||||
|
||||
@@ -596,12 +608,16 @@ func reel(player: Node3D, world: Node3D) -> bool:
|
||||
var caught := ""
|
||||
if b.state == "biting":
|
||||
caught = b.fish
|
||||
Sfx.at("reel", player.global_position)
|
||||
if not caught.is_empty():
|
||||
Sfx.at("catch", b.global_position)
|
||||
world.end_fishing(player)
|
||||
if caught.is_empty():
|
||||
return false
|
||||
if _give(player, caught, 1) == 0:
|
||||
world.drop_item(caught, 1, Transform3D(Basis(), player.global_position + Vector3(0, 0.1, 0)))
|
||||
notify.emit("Caught a %s" % ItemDB.item_name(caught).to_lower())
|
||||
Sfx.for_player(player, "ui_catch")
|
||||
emit_signal("caught", player, caught) # (the local `caught` hides the signal's name)
|
||||
return true
|
||||
|
||||
@@ -618,6 +634,7 @@ func wear(player: Node3D, slot: int) -> bool:
|
||||
_give(player, player.worn, 1)
|
||||
player.worn = id
|
||||
player.worn_changed.emit()
|
||||
Sfx.at("wear", player.global_position)
|
||||
notify.emit("Wearing %s" % ItemDB.item_name(id).to_lower())
|
||||
return true
|
||||
|
||||
@@ -663,6 +680,7 @@ func eat(player: Node3D, slot: int) -> bool:
|
||||
player.inventory.take_from(slot, 1)
|
||||
var eaten := String(s.id)
|
||||
player.survival.eat(food)
|
||||
Sfx.at("eat", player.global_position)
|
||||
notify.emit("Ate %s" % ItemDB.item_name(eaten).to_lower())
|
||||
ate.emit(player, eaten)
|
||||
# Raw meat and fish can make you ill (P4). Cooked, smoked or salted can't.
|
||||
@@ -688,6 +706,7 @@ func add_fuel(player: Node3D, fire: Node3D) -> bool:
|
||||
if player.inventory.count(id) > 0:
|
||||
player.inventory.remove(id, 1)
|
||||
fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10)))
|
||||
Sfx.at("fuel", fire.global_position)
|
||||
notify.emit("Added %s to the fire" % ItemDB.item_name(id).to_lower())
|
||||
return true
|
||||
notify.emit("Nothing to burn (wood or sticks)")
|
||||
@@ -703,3 +722,22 @@ func transfer(player: Node3D, from: Inventory, slot: int, to: Inventory, contain
|
||||
_ask("transfer", [WorldNet.nid(container), slot, from == player.inventory])
|
||||
return
|
||||
from.quick_move(slot, to)
|
||||
|
||||
# --- sounds (sfx.gd) --------------------------------------------------------------------
|
||||
|
||||
## The sound of a tool on a tree, a boulder or a bush; heavier when it falls.
|
||||
static func harvest_sound(tool_kind: String, felled: bool) -> String:
|
||||
match tool_kind:
|
||||
"axe":
|
||||
return "tree_fall" if felled else "chop"
|
||||
"pickaxe":
|
||||
return "rock_break" if felled else "mine"
|
||||
return "rustle"
|
||||
|
||||
## A piece going down: wood, stone, or something soft.
|
||||
static func place_sound(id: String) -> String:
|
||||
if id.contains("stone") or id in ["campfire", "forge", "hearth", "stonecutter"]:
|
||||
return "place_stone"
|
||||
if id.contains("rug") or id.contains("bed") or id == "smoking_rack":
|
||||
return "place_soft"
|
||||
return "place_wood"
|
||||
|
||||
227
scripts/core/music.gd
Normal file
227
scripts/core/music.gd
Normal file
@@ -0,0 +1,227 @@
|
||||
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 _tracks: Dictionary = {}
|
||||
var _rng := RandomNumberGenerator.new()
|
||||
var _quiet := false
|
||||
var _fading := 0 ## crossfades in progress
|
||||
|
||||
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))
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
var k := clampf(dt * 0.8, 0.0, 1.0)
|
||||
_birds.volume_db = lerpf(_birds.volume_db, linear_to_db(maxf(birds * 0.8, 0.0001)), k)
|
||||
_crickets.volume_db = lerpf(_crickets.volume_db, linear_to_db(maxf(crickets * 0.7, 0.0001)), k)
|
||||
if _cur != null and _cur.playing and _fading == 0:
|
||||
_cur.volume_db = lerpf(_cur.volume_db, _target_db(), k)
|
||||
282
scripts/core/sfx.gd
Normal file
282
scripts/core/sfx.gd
Normal file
@@ -0,0 +1,282 @@
|
||||
extends Node
|
||||
## Sound effects. Recorded ones are Kenney's CC0 packs (assets/audio/sfx/kenney,
|
||||
## see assets/audio/CREDITS.md); the rest are synthesised by
|
||||
## tools/audio/make_audio.py (assets/audio/sfx/synth). Each sound has a few
|
||||
## variants and a little pitch spread, so repeats don't sound mechanical.
|
||||
##
|
||||
## - `at(id, pos)`: something happened in the world. In single-player it plays
|
||||
## there. On a server (which has no speakers) it's sent to every player
|
||||
## within earshot, who hear it from that spot: you hear a friend chopping.
|
||||
## - `for_player(player, id)`: a sound only that player hears, close up
|
||||
## (eating, getting hurt). On a server, sent to that player's game.
|
||||
## - `ui(id)`: menus and the HUD, only ever on this machine.
|
||||
## Headless (tests, a dedicated server) nothing is played, but every sound
|
||||
## asked for is logged in `heard`.
|
||||
##
|
||||
## Volumes: buses Music, SFX and Ambience under Master, set in the pause menu
|
||||
## and kept in user://audio.json.
|
||||
|
||||
const K := "res://assets/audio/sfx/kenney/"
|
||||
const S := "res://assets/audio/sfx/synth/"
|
||||
const C := "res://assets/audio/sfx/creatures/"
|
||||
const EARSHOT := 45.0 ## a server sends a sound to players this close
|
||||
|
||||
## id -> files (or a [prefix, count] pair), volume (dB), pitch range, reach (m),
|
||||
## and an optional second sound layered on top.
|
||||
const SOUNDS := {
|
||||
"chop": {"set": [K + "chop_%d.ogg", 5], "db": -2.0, "pitch": [0.9, 1.1]},
|
||||
"tree_fall": {"set": [K + "wood_heavy_%d.ogg", 5], "db": 0.0, "pitch": [0.6, 0.7], "reach": 60.0, "with": "creak"},
|
||||
"creak": {"set": [K + "creak_%d.ogg", 3], "db": -2.0, "pitch": [0.8, 0.95], "reach": 50.0},
|
||||
"mine": {"set": [K + "mine_%d.ogg", 5], "db": -1.0, "pitch": [0.9, 1.1]},
|
||||
"rock_break": {"set": [K + "mine_%d.ogg", 5], "db": 1.0, "pitch": [0.6, 0.7], "reach": 50.0},
|
||||
"rustle": {"set": [K + "cloth_%d.ogg", 4], "db": -4.0, "pitch": [0.7, 0.9]},
|
||||
"pickup": {"set": [K + "leather_%d.ogg", 3], "db": -6.0, "pitch": [0.95, 1.15], "reach": 15.0},
|
||||
"craft": {"set": [K + "metal_click_%d.ogg", 2], "db": -6.0, "pitch": [0.9, 1.1], "reach": 15.0, "with": "pickup"},
|
||||
"place_wood": {"set": [K + "plank_%d.ogg", 5], "db": -1.0, "pitch": [0.85, 1.05]},
|
||||
"place_stone": {"set": [K + "mine_%d.ogg", 5], "db": -3.0, "pitch": [1.1, 1.3]},
|
||||
"place_soft": {"set": [K + "cloth_%d.ogg", 4], "db": -3.0, "pitch": [0.8, 1.0], "reach": 15.0},
|
||||
"dismantle": {"set": [K + "wood_heavy_%d.ogg", 5], "db": -2.0, "pitch": [1.0, 1.15]},
|
||||
"door_open": {"set": [K + "door_open_%d.ogg", 2], "db": -3.0, "pitch": [0.95, 1.05]},
|
||||
"door_close": {"set": [K + "door_close_%d.ogg", 4], "db": -3.0, "pitch": [0.95, 1.05]},
|
||||
"swing": {"set": [S + "swing_%d.ogg", 3], "db": -9.0, "pitch": [0.9, 1.15], "reach": 12.0},
|
||||
"hit_flesh": {"set": [K + "punch_%d.ogg", 5], "db": -2.0, "pitch": [0.85, 1.05]},
|
||||
"hit_bone": {"set": [K + "wood_light_%d.ogg", 5], "db": -1.0, "pitch": [1.2, 1.4]},
|
||||
"creature_die": {"set": [K + "soft_heavy_%d.ogg", 5], "db": 0.0, "pitch": [0.7, 0.85]},
|
||||
"butcher": {"set": [K + "knife_%d.ogg", 2], "db": -3.0, "pitch": [0.9, 1.1], "reach": 15.0},
|
||||
"eat": {"set": [S + "eat_%d.ogg", 3], "db": -5.0, "pitch": [0.9, 1.15], "reach": 8.0},
|
||||
"hurt": {"set": [K + "punch_heavy_%d.ogg", 5], "db": -2.0, "pitch": [0.8, 0.95], "with": "thump"},
|
||||
"thump": {"set": [S + "thump_%d.ogg", 2], "db": -5.0, "pitch": [0.9, 1.1]},
|
||||
"death": {"files": [S + "death.ogg"], "db": -2.0, "pitch": [1.0, 1.0]},
|
||||
"cast": {"files": [S + "cast.ogg"], "db": -6.0, "pitch": [0.95, 1.1], "reach": 20.0},
|
||||
"splash": {"set": [S + "splash_%d.ogg", 2], "db": -4.0, "pitch": [0.9, 1.15], "reach": 30.0},
|
||||
"swim": {"set": [S + "splash_%d.ogg", 2], "db": -14.0, "pitch": [1.2, 1.5], "reach": 15.0},
|
||||
"bite": {"files": [S + "plop.ogg"], "db": -3.0, "pitch": [0.9, 1.1], "reach": 25.0},
|
||||
"reel": {"files": [S + "reel.ogg"], "db": -7.0, "pitch": [0.95, 1.05], "reach": 15.0},
|
||||
"catch": {"files": [S + "splash_big.ogg"], "db": -3.0, "pitch": [0.95, 1.05], "reach": 30.0},
|
||||
"bow": {"files": [S + "bow.ogg"], "db": -4.0, "pitch": [0.92, 1.08], "reach": 25.0},
|
||||
"throw": {"set": [S + "swing_%d.ogg", 3], "db": -5.0, "pitch": [0.7, 0.8], "reach": 20.0},
|
||||
"fuel": {"set": [K + "wood_light_%d.ogg", 5], "db": -4.0, "pitch": [0.8, 1.0], "reach": 15.0},
|
||||
"wear": {"set": [K + "cloth_%d.ogg", 4], "db": -4.0, "pitch": [0.9, 1.1], "reach": 10.0},
|
||||
"step_grass": {"set": [K + "step_grass_%d.ogg", 5], "db": -15.0, "pitch": [0.9, 1.1], "reach": 12.0},
|
||||
"step_snow": {"set": [K + "step_snow_%d.ogg", 5], "db": -13.0, "pitch": [0.9, 1.1], "reach": 12.0},
|
||||
"step_wood": {"set": [K + "step_wood_%d.ogg", 5], "db": -12.0, "pitch": [0.9, 1.1], "reach": 12.0},
|
||||
"step_stone": {"set": [K + "step_stone_%d.ogg", 5], "db": -13.0, "pitch": [0.9, 1.1], "reach": 12.0},
|
||||
"ui_open": {"files": [K + "ui_open_0.ogg"], "db": -10.0, "pitch": [1.0, 1.0]},
|
||||
"ui_close": {"files": [K + "ui_close_0.ogg"], "db": -10.0, "pitch": [1.0, 1.0]},
|
||||
"ui_tick": {"files": [K + "ui_tick_0.ogg"], "db": -14.0, "pitch": [0.95, 1.05]},
|
||||
"ui_good": {"files": [K + "ui_good_0.ogg"], "db": -8.0, "pitch": [1.0, 1.0]},
|
||||
"ui_step": {"files": [K + "ui_step_0.ogg"], "db": -8.0, "pitch": [1.0, 1.0]},
|
||||
"ui_catch": {"files": [K + "ui_catch_0.ogg"], "db": -8.0, "pitch": [1.0, 1.0]},
|
||||
"ui_tip": {"files": [K + "ui_tip_0.ogg"], "db": -12.0, "pitch": [1.0, 1.0]},
|
||||
# Creature voices (creature.gd's VOICES picks them).
|
||||
"wolf_growl": {"set": [C + "wolf_growl_%d.ogg", 3], "db": -3.0, "pitch": [0.92, 1.08], "reach": 30.0},
|
||||
"wolf_snarl": {"set": [C + "wolf_snarl_%d.ogg", 2], "db": -1.0, "pitch": [0.92, 1.1], "reach": 25.0},
|
||||
"wolf_yelp": {"set": [C + "wolf_yelp_%d.ogg", 2], "db": -3.0, "pitch": [0.92, 1.1], "reach": 30.0},
|
||||
"alpha_growl": {"set": [C + "wolf_growl_%d.ogg", 3], "db": 0.0, "pitch": [0.68, 0.76], "reach": 40.0},
|
||||
"alpha_snarl": {"set": [C + "wolf_snarl_%d.ogg", 2], "db": 1.0, "pitch": [0.7, 0.78], "reach": 30.0},
|
||||
"alpha_yelp": {"set": [C + "wolf_yelp_%d.ogg", 2], "db": -1.0, "pitch": [0.72, 0.8], "reach": 35.0},
|
||||
"spirit_growl": {"set": [C + "wolf_growl_%d.ogg", 3], "db": -7.0, "pitch": [1.15, 1.3], "reach": 25.0},
|
||||
"spirit_snarl": {"set": [C + "wolf_snarl_%d.ogg", 2], "db": -5.0, "pitch": [1.15, 1.3], "reach": 20.0},
|
||||
"bear_roar": {"set": [C + "bear_roar_%d.ogg", 2], "db": 2.0, "pitch": [0.92, 1.05], "reach": 70.0},
|
||||
"bear_grunt": {"set": [C + "bear_grunt_%d.ogg", 2], "db": 0.0, "pitch": [0.85, 1.05], "reach": 30.0},
|
||||
"bear_hurt": {"set": [C + "bear_grunt_%d.ogg", 2], "db": 1.0, "pitch": [1.2, 1.35], "reach": 35.0},
|
||||
"boar_grunt": {"set": [C + "boar_grunt_%d.ogg", 2], "db": -3.0, "pitch": [0.9, 1.1], "reach": 22.0},
|
||||
"boar_squeal": {"files": [C + "boar_squeal_0.ogg"], "db": -2.0, "pitch": [0.9, 1.1], "reach": 35.0},
|
||||
"stag_bellow": {"set": [C + "stag_bellow_%d.ogg", 2], "db": 3.0, "pitch": [0.92, 1.05], "reach": 90.0},
|
||||
"stag_snort": {"files": [C + "stag_snort_0.ogg"], "db": -1.0, "pitch": [0.85, 1.0], "reach": 30.0},
|
||||
"guard_rattle": {"set": [C + "guard_rattle_%d.ogg", 2], "db": -2.0, "pitch": [0.85, 1.1], "reach": 25.0},
|
||||
"crow_caw": {"set": [C + "crow_caw_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.1], "reach": 40.0},
|
||||
"fox_yip": {"set": [C + "fox_yip_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.12], "reach": 30.0},
|
||||
}
|
||||
const BUSES := ["Music", "SFX", "Ambience"]
|
||||
const DEFAULT_VOLUMES := {"Master": 0.85, "Music": 0.6, "SFX": 0.9, "Ambience": 0.7}
|
||||
const POOL_3D := 20
|
||||
const POOL_2D := 6
|
||||
|
||||
var prefs_path := "user://audio.json"
|
||||
var volumes: Dictionary = DEFAULT_VOLUMES.duplicate()
|
||||
var heard: Array = [] ## [id, position or null] of each sound asked for (newest last)
|
||||
var ids: Array = [] ## SOUNDS' keys, sorted: a sound's number on the network
|
||||
var _streams: Dictionary = {} ## id -> [AudioStream]
|
||||
var _p3: Array = []
|
||||
var _p2: Array = []
|
||||
var _i3 := 0
|
||||
var _i2 := 0
|
||||
var _rng := RandomNumberGenerator.new()
|
||||
var _quiet := false ## headless: log, don't play
|
||||
|
||||
func _ready() -> void:
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
ids = SOUNDS.keys()
|
||||
ids.sort()
|
||||
_rng.randomize()
|
||||
_quiet = DisplayServer.get_name() == "headless"
|
||||
for b in BUSES:
|
||||
if AudioServer.get_bus_index(b) == -1:
|
||||
AudioServer.add_bus()
|
||||
var i := AudioServer.bus_count - 1
|
||||
AudioServer.set_bus_name(i, b)
|
||||
AudioServer.set_bus_send(i, "Master")
|
||||
load_prefs()
|
||||
if _quiet:
|
||||
return
|
||||
for i in range(POOL_3D):
|
||||
var p := AudioStreamPlayer3D.new()
|
||||
p.bus = "SFX"
|
||||
p.attenuation_model = AudioStreamPlayer3D.ATTENUATION_INVERSE_DISTANCE
|
||||
p.unit_size = 4.0
|
||||
add_child(p)
|
||||
_p3.append(p)
|
||||
for i in range(POOL_2D):
|
||||
var p := AudioStreamPlayer.new()
|
||||
p.bus = "SFX"
|
||||
add_child(p)
|
||||
_p2.append(p)
|
||||
|
||||
# --- volumes ---------------------------------------------------------------------
|
||||
|
||||
func load_prefs() -> void:
|
||||
volumes = DEFAULT_VOLUMES.duplicate()
|
||||
if FileAccess.file_exists(prefs_path):
|
||||
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(prefs_path))
|
||||
if d is Dictionary:
|
||||
for k in volumes:
|
||||
if d.has(k):
|
||||
volumes[k] = clampf(float(d[k]), 0.0, 1.0)
|
||||
for k in volumes:
|
||||
_apply_volume(k)
|
||||
|
||||
func set_volume(bus: String, v: float) -> void:
|
||||
volumes[bus] = clampf(v, 0.0, 1.0)
|
||||
_apply_volume(bus)
|
||||
var f := FileAccess.open(prefs_path, FileAccess.WRITE)
|
||||
if f != null:
|
||||
f.store_string(JSON.stringify(volumes, "\t"))
|
||||
|
||||
func _apply_volume(bus: String) -> void:
|
||||
var i := AudioServer.get_bus_index(bus)
|
||||
if i == -1:
|
||||
return
|
||||
var v: float = volumes[bus]
|
||||
AudioServer.set_bus_mute(i, v <= 0.001)
|
||||
AudioServer.set_bus_volume_db(i, linear_to_db(maxf(v, 0.001)))
|
||||
|
||||
# --- playing -----------------------------------------------------------------------
|
||||
|
||||
## Something happened at `pos` in the world.
|
||||
func at(id: String, pos: Vector3) -> void:
|
||||
if not SOUNDS.has(id):
|
||||
push_warning("[sfx] no sound '%s'" % id)
|
||||
return
|
||||
_log(id, pos)
|
||||
if Net.is_server():
|
||||
var n := ids.find(id)
|
||||
for peer in Net.players:
|
||||
var pp: Vector3 = Net.player_pos(int(peer))
|
||||
if pp != Vector3.INF and pp.distance_to(pos) <= float(SOUNDS[id].get("reach", 30.0)) + 5.0:
|
||||
_s2c_at.rpc_id(int(peer), n, pos)
|
||||
return
|
||||
_play3(id, pos)
|
||||
|
||||
## Only `player` hears it, close up (their own eating, getting hurt).
|
||||
func for_player(player: Node, id: String) -> void:
|
||||
if not SOUNDS.has(id):
|
||||
return
|
||||
if Net.is_server():
|
||||
_log(id, null)
|
||||
if player != null and player.get("peer") != null:
|
||||
_s2c_me.rpc_id(int(player.peer), ids.find(id))
|
||||
return
|
||||
me(id)
|
||||
|
||||
## A sound for this machine's own player (not sent anywhere).
|
||||
func me(id: String) -> void:
|
||||
_log(id, null)
|
||||
_play2(id)
|
||||
|
||||
## Menus and the HUD.
|
||||
func ui(id: String) -> void:
|
||||
_log(id, null)
|
||||
_play2(id)
|
||||
|
||||
@rpc("authority", "call_remote", "unreliable_ordered")
|
||||
func _s2c_at(n: int, pos: Vector3) -> void:
|
||||
if n >= 0 and n < ids.size():
|
||||
_log(ids[n], pos)
|
||||
_play3(ids[n], pos)
|
||||
|
||||
@rpc("authority", "call_remote", "unreliable_ordered")
|
||||
func _s2c_me(n: int) -> void:
|
||||
if n >= 0 and n < ids.size():
|
||||
_log(ids[n], null)
|
||||
_play2(ids[n])
|
||||
|
||||
func _log(id: String, pos: Variant) -> void:
|
||||
heard.append([id, pos])
|
||||
if heard.size() > 400:
|
||||
heard = heard.slice(200)
|
||||
|
||||
## How often `id` was asked for since the log was last cleared (tests).
|
||||
func count(id: String) -> int:
|
||||
var n := 0
|
||||
for h in heard:
|
||||
if h[0] == id:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
func _play3(id: String, pos: Vector3) -> void:
|
||||
if _quiet or _p3.is_empty():
|
||||
return
|
||||
var d: Dictionary = SOUNDS[id]
|
||||
var st := _pick(id)
|
||||
if st == null:
|
||||
return
|
||||
var p: AudioStreamPlayer3D = _p3[_i3]
|
||||
_i3 = (_i3 + 1) % _p3.size()
|
||||
p.stop()
|
||||
p.stream = st
|
||||
p.volume_db = float(d.get("db", 0.0))
|
||||
p.max_distance = float(d.get("reach", 30.0))
|
||||
p.pitch_scale = _rng.randf_range(d.pitch[0], d.pitch[1])
|
||||
p.global_position = pos
|
||||
p.play()
|
||||
if d.has("with"):
|
||||
_play3(String(d["with"]), pos)
|
||||
|
||||
func _play2(id: String) -> void:
|
||||
if _quiet or _p2.is_empty():
|
||||
return
|
||||
var d: Dictionary = SOUNDS[id]
|
||||
var st := _pick(id)
|
||||
if st == null:
|
||||
return
|
||||
var p: AudioStreamPlayer = _p2[_i2]
|
||||
_i2 = (_i2 + 1) % _p2.size()
|
||||
p.stop()
|
||||
p.stream = st
|
||||
p.volume_db = float(d.get("db", 0.0))
|
||||
p.pitch_scale = _rng.randf_range(d.pitch[0], d.pitch[1])
|
||||
p.play()
|
||||
if d.has("with"):
|
||||
_play2(String(d["with"]))
|
||||
|
||||
func _pick(id: String) -> AudioStream:
|
||||
if not _streams.has(id):
|
||||
var got: Array = []
|
||||
for path in files(id):
|
||||
var s: Variant = load(path)
|
||||
if s is AudioStream:
|
||||
got.append(s)
|
||||
_streams[id] = got
|
||||
var l: Array = _streams[id]
|
||||
return l[_rng.randi() % l.size()] if not l.is_empty() else null
|
||||
|
||||
## A sound's files.
|
||||
static func files(id: String) -> Array:
|
||||
var d: Dictionary = SOUNDS[id]
|
||||
if d.has("files"):
|
||||
return d.files
|
||||
var out: Array = []
|
||||
for i in range(int(d.set[1])):
|
||||
out.append(String(d.set[0]) % i)
|
||||
return out
|
||||
@@ -217,11 +217,14 @@ func step(dt: float) -> void:
|
||||
_aggro = maxf(_aggro - dt, 0.0)
|
||||
# Who it's reacting to: the player (or on a server, the nearest one).
|
||||
var t := _pick_target()
|
||||
var was := state
|
||||
var cd0 := _attack_cd
|
||||
if t != null:
|
||||
player = t
|
||||
_think(dt)
|
||||
elif state in ["flee", "charge"]:
|
||||
state = "idle" # nobody about
|
||||
_voice(dt, was, cd0)
|
||||
_move(dt)
|
||||
_animate(dt)
|
||||
|
||||
@@ -410,6 +413,13 @@ func take_hit(damage: float, from: Vector3, embed_id: String = "") -> void:
|
||||
hp -= damage
|
||||
if not embed_id.is_empty():
|
||||
embedded[embed_id] = embedded.get(embed_id, 0) + 1
|
||||
Sfx.at("hit_bone" if bool(def.get("bone", false)) else "hit_flesh", global_position + Vector3(0, 0.6, 0))
|
||||
var cry := String(VOICES.get(kind, {}).get("hurt", ""))
|
||||
if not cry.is_empty() and _hurt_voice_cd <= 0.0:
|
||||
Sfx.at(cry, global_position + Vector3(0, 0.8, 0))
|
||||
_hurt_voice_cd = 0.6 # (a flurry of arrows: one cry, not five)
|
||||
if hp <= 0.0:
|
||||
Sfx.at("creature_die", global_position)
|
||||
_flash = 0.15
|
||||
_set_material(_hit_mat)
|
||||
_burst()
|
||||
@@ -491,3 +501,60 @@ func _burst() -> void:
|
||||
p.global_position = global_position + Vector3(0, Meshes.build(kind).height * 0.6, 0)
|
||||
p.emitting = true
|
||||
p.finished.connect(p.queue_free)
|
||||
|
||||
# --- voices (sfx.gd) ---------------------------------------------------------------------
|
||||
|
||||
## What each creature sounds like: "alert" when it turns on someone, "angry"
|
||||
## now and then while it's after them, "attack" as it strikes, "hurt" when hit,
|
||||
## and "idle" now and then while it's minding its own business. Played by
|
||||
## whoever runs the creature (single-player, or the server, which sends them on).
|
||||
const VOICES := {
|
||||
"wolf": {"alert": "wolf_growl", "angry": "wolf_growl", "attack": "wolf_snarl", "hurt": "wolf_yelp"},
|
||||
"alpha": {"alert": "alpha_growl", "angry": "alpha_growl", "attack": "alpha_snarl", "hurt": "alpha_yelp"},
|
||||
"spirit_wolf": {"alert": "spirit_growl", "angry": "spirit_growl", "attack": "spirit_snarl", "hurt": "spirit_growl"},
|
||||
"bear": {"alert": "bear_roar", "angry": "bear_grunt", "attack": "bear_grunt", "hurt": "bear_hurt", "idle": "bear_grunt"},
|
||||
"boar": {"alert": "boar_grunt", "angry": "boar_grunt", "attack": "boar_grunt", "hurt": "boar_squeal", "idle": "boar_grunt"},
|
||||
"old_stag": {"alert": "stag_bellow", "angry": "stag_bellow", "attack": "stag_snort", "hurt": "stag_snort"},
|
||||
"guard": {"alert": "guard_rattle", "angry": "guard_rattle", "attack": "guard_rattle"},
|
||||
"crow": {"alert": "crow_caw", "hurt": "crow_caw", "idle": "crow_caw"},
|
||||
"fox": {"alert": "fox_yip", "hurt": "fox_yip"},
|
||||
}
|
||||
## States a creature is in when it's after someone (music.gd's list too).
|
||||
const ANGRY := ["charge", "stalk", "lunge", "bash", "stomp", "telegraph", "warn", "raid", "summon"]
|
||||
const ANGRY_EVERY := [4.0, 8.0]
|
||||
const IDLE_EVERY := [18.0, 45.0]
|
||||
var _voice_left := -1.0
|
||||
var _hurt_voice_cd := 0.0
|
||||
## Its own dice, so voices never change what the creature does (the
|
||||
## behaviour tests replay the same choices from the same seed).
|
||||
var _voice_rng := RandomNumberGenerator.new()
|
||||
|
||||
func _voice(dt: float, was: String, cd0: float) -> void:
|
||||
_hurt_voice_cd = maxf(_hurt_voice_cd - dt, 0.0)
|
||||
var v: Dictionary = VOICES.get(kind, {})
|
||||
if v.is_empty():
|
||||
return
|
||||
var mouth := global_position + Vector3(0, 0.8, 0)
|
||||
if _attack_cd > cd0 + 0.05 and v.has("attack"):
|
||||
Sfx.at(String(v.attack), mouth)
|
||||
_voice_left = maxf(_voice_left, 1.5)
|
||||
return
|
||||
var angry := state in ANGRY
|
||||
if angry and not (was in ANGRY) and v.has("alert"):
|
||||
Sfx.at(String(v.alert), mouth)
|
||||
_voice_left = _voice_rng.randf_range(ANGRY_EVERY[0], ANGRY_EVERY[1])
|
||||
return
|
||||
if _voice_left < 0.0:
|
||||
_voice_rng.seed = get_instance_id()
|
||||
_voice_left = _voice_rng.randf_range(2.0, IDLE_EVERY[1]) # (a new creature doesn't call out at once)
|
||||
_voice_left -= dt
|
||||
if _voice_left > 0.0:
|
||||
return
|
||||
if angry and v.has("angry"):
|
||||
Sfx.at(String(v.angry), mouth)
|
||||
_voice_left = _voice_rng.randf_range(ANGRY_EVERY[0], ANGRY_EVERY[1])
|
||||
elif not angry and v.has("idle") and state != "rest" and state != "flee":
|
||||
Sfx.at(String(v.idle), mouth)
|
||||
_voice_left = _voice_rng.randf_range(IDLE_EVERY[0], IDLE_EVERY[1])
|
||||
else:
|
||||
_voice_left = _voice_rng.randf_range(ANGRY_EVERY[0], ANGRY_EVERY[1])
|
||||
|
||||
@@ -126,6 +126,7 @@ func _think(dt: float) -> void:
|
||||
p.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "the Old Stag")
|
||||
_hit_this_charge = true
|
||||
charge_hits += 1
|
||||
Sfx.at("stag_snort", global_position + Vector3(0, 1.2, 0))
|
||||
var left := Vector2(_target.x - global_position.x, _target.z - global_position.z)
|
||||
if left.dot(Vector2(_dir.x, _dir.z)) <= 0.0 or _state_t <= 0.0:
|
||||
state = "stunned"
|
||||
@@ -140,6 +141,7 @@ func _think(dt: float) -> void:
|
||||
for p in world.players():
|
||||
if p.global_position.distance_to(global_position) < STOMP_R and not p.survival.dead:
|
||||
p.survival.damage(STOMP_DAMAGE * world.difficulty_mult("damage"), global_position, "the Old Stag")
|
||||
Sfx.at("rock_break", global_position)
|
||||
stones.stomped(self)
|
||||
_start_circle()
|
||||
_:
|
||||
|
||||
@@ -128,6 +128,7 @@ func step(dt: float) -> void:
|
||||
p.y = ground + 0.03
|
||||
state = "landed"
|
||||
Actions.tell(player, "Cast into the water")
|
||||
Sfx.at("splash", p)
|
||||
global_position = p
|
||||
"floating":
|
||||
wait_left -= dt
|
||||
@@ -137,6 +138,7 @@ func step(dt: float) -> void:
|
||||
fish = _pick_fish()
|
||||
bite_left = BITE_WINDOW
|
||||
Actions.tell(player, "Bite! Click to reel in")
|
||||
Sfx.at("bite", global_position)
|
||||
"biting":
|
||||
bite_left -= dt
|
||||
# The float bobs hard and goes under.
|
||||
|
||||
@@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day
|
||||
const Identity = preload("res://scripts/net/identity.gd")
|
||||
const ServerConfig = preload("res://scripts/net/server_config.gd")
|
||||
|
||||
const PROTOCOL := 12 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5)
|
||||
const PROTOCOL := 13 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5); 13: sounds sent to players nearby (sfx.gd)
|
||||
const DEFAULT_PORT := 24242
|
||||
const HANDSHAKE_TIMEOUT := 10.0
|
||||
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join
|
||||
|
||||
@@ -339,6 +339,7 @@ func swing() -> void:
|
||||
if _swing_cd > 0.0:
|
||||
return
|
||||
_swing_cd = float(Actions.melee_stats(player).cooldown)
|
||||
get_tree().create_timer(0.12).timeout.connect(func(): Sfx.me("swing")) # (as it comes down)
|
||||
var tw := create_tween()
|
||||
var wind := Transform3D(_view_rest.basis.rotated(Vector3.RIGHT, deg_to_rad(35)), _view_rest.origin + Vector3(0, 0.08, 0.05))
|
||||
var strike := Transform3D(_view_rest.basis.rotated(Vector3.RIGHT, deg_to_rad(-70)), _view_rest.origin + Vector3(-0.05, -0.05, -0.15))
|
||||
|
||||
@@ -103,3 +103,42 @@ func _physics_process(delta: float) -> void:
|
||||
velocity.z = move_toward(velocity.z, 0, current_speed)
|
||||
|
||||
move_and_slide()
|
||||
_footsteps(delta, swimming)
|
||||
|
||||
# --- footsteps (sfx.gd) ------------------------------------------------------------
|
||||
|
||||
const STRIDE := 1.9 ## metres between footsteps (walking; a little longer sprinting)
|
||||
var _stride := 0.0
|
||||
|
||||
## A footstep every stride on the ground, by what's underfoot (a floor
|
||||
## piece's wood or stone, snow up high or while it's snowing, else grass);
|
||||
## a stroke now and then while swimming. Only you hear your own.
|
||||
func _footsteps(dt: float, swimming: bool) -> void:
|
||||
var v := Vector2(velocity.x, velocity.z).length()
|
||||
if v < 1.0 or not (is_on_floor() or swimming):
|
||||
_stride = minf(_stride, STRIDE * 0.6) # (the first step after stopping comes soon)
|
||||
return
|
||||
_stride += v * dt
|
||||
var gap := STRIDE * (1.2 if v > speed + 0.5 else 1.0) * (1.6 if swimming else 1.0)
|
||||
if _stride < gap:
|
||||
return
|
||||
_stride = 0.0
|
||||
Sfx.me("swim" if swimming else step_sound())
|
||||
|
||||
func step_sound() -> String:
|
||||
var q := PhysicsRayQueryParameters3D.create(global_position + Vector3(0, 0.3, 0), global_position - Vector3(0, 0.5, 0))
|
||||
q.exclude = [get_rid()]
|
||||
var hit := get_world_3d().direct_space_state.intersect_ray(q)
|
||||
var c: Object = hit.get("collider")
|
||||
# (a piece's collider is its body or one of its children)
|
||||
for _i in range(3):
|
||||
if not (c is Node):
|
||||
break
|
||||
if (c as Node).is_in_group("piece"):
|
||||
return "step_stone" if String((c as Node).get("item_id")).contains("stone") else "step_wood"
|
||||
c = (c as Node).get_parent()
|
||||
var w: Node = get_parent()
|
||||
var wx: Node = w.get("weather") if w != null else null
|
||||
if wx != null and (String(wx.get("state")) in ["snow", "blizzard"] or global_position.y > float(wx.get("snowline")) - 1.0):
|
||||
return "step_snow"
|
||||
return "step_grass"
|
||||
|
||||
@@ -83,6 +83,7 @@ func _redraw() -> void:
|
||||
_progress.text = Guide.progress_text()
|
||||
|
||||
func _on_done(_i: int, s: Dictionary) -> void:
|
||||
Sfx.ui("ui_step")
|
||||
_tick.text = "✓ " + String(s.title)
|
||||
var tw := create_tween()
|
||||
_tick.modulate.a = 1.0
|
||||
|
||||
@@ -37,6 +37,7 @@ func setup() -> void:
|
||||
Hints.shown.connect(show_hint)
|
||||
|
||||
func show_hint(h: Dictionary) -> void:
|
||||
Sfx.ui("ui_tip")
|
||||
_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
|
||||
|
||||
@@ -99,6 +99,7 @@ func bind(p: CharacterBody3D) -> void:
|
||||
|
||||
player.inventory.changed.connect(_refresh)
|
||||
player.selected_changed.connect(_refresh)
|
||||
player.selected_changed.connect(func(): Sfx.ui("ui_tick"))
|
||||
player.inventory_requested.connect(_on_inventory_requested)
|
||||
player.hands.prompt_changed.connect(func(t): _prompt.text = t)
|
||||
_hurt = ColorRect.new()
|
||||
|
||||
@@ -144,12 +144,15 @@ func open(chest: Node) -> void:
|
||||
container.inventory.changed.connect(_refresh)
|
||||
visible = true
|
||||
player.ui_open = true
|
||||
Sfx.ui("ui_open")
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
_pick_tab()
|
||||
_rebuild_chest()
|
||||
_refresh()
|
||||
|
||||
func close() -> void:
|
||||
if visible:
|
||||
Sfx.ui("ui_close")
|
||||
if WorldNet.is_client():
|
||||
PlayerNet.ask("close") # the server puts the cursor back in our bag
|
||||
_cursor = null
|
||||
|
||||
@@ -80,6 +80,25 @@ func bind(p: CharacterBody3D) -> void:
|
||||
sens_row.add_child(sens)
|
||||
_main.add_child(sens_row)
|
||||
|
||||
# Volumes (sfx.gd keeps them in user://audio.json).
|
||||
for row in [["Music", "Music"], ["Sounds", "SFX"], ["Ambience", "Ambience"]]:
|
||||
var vr := HBoxContainer.new()
|
||||
var lab := UITheme.label(row[0], 16)
|
||||
lab.custom_minimum_size = Vector2(150, 0)
|
||||
vr.add_child(lab)
|
||||
var vs := HSlider.new()
|
||||
vs.min_value = 0.0
|
||||
vs.max_value = 1.0
|
||||
vs.step = 0.05
|
||||
vs.value = float(Sfx.volumes.get(row[1], 0.8))
|
||||
vs.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
vs.focus_mode = Control.FOCUS_NONE
|
||||
vs.accessibility_name = "%s volume" % row[0]
|
||||
var bus: String = row[1]
|
||||
vs.value_changed.connect(func(v: float): Sfx.set_volume(bus, v))
|
||||
vr.add_child(vs)
|
||||
_main.add_child(vr)
|
||||
|
||||
# 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()
|
||||
|
||||
@@ -147,6 +147,9 @@ func _ready() -> void:
|
||||
hud.bind(player)
|
||||
if not menu_mode and not server_mode:
|
||||
player.survival.died.connect(_on_player_died)
|
||||
player.survival.hurt.connect(func(_amount, _from): Sfx.me("hurt"))
|
||||
if not server_mode:
|
||||
Music.start(self) # (the title screen's backdrop world too: calm music)
|
||||
if Saves.has_world() and not menu_mode and not server_mode:
|
||||
# Save on window close instead of quitting straight away.
|
||||
get_tree().auto_accept_quit = false
|
||||
@@ -188,6 +191,7 @@ func heat_at(pos: Vector3, holder: Node = null) -> float:
|
||||
## wake up at the island start. The world carries on.
|
||||
func _on_player_died() -> void:
|
||||
Hints.event("died")
|
||||
Sfx.me("death")
|
||||
var pos := player.global_position
|
||||
pos.y = terrain.sample_height(pos.x, pos.z)
|
||||
if pos.y < terrain.water_level:
|
||||
@@ -247,6 +251,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")
|
||||
Sfx.me("death")
|
||||
dead_waiting = true
|
||||
player.velocity = Vector3.ZERO
|
||||
player.set_physics_process(false)
|
||||
@@ -561,6 +566,7 @@ func _exit_tree() -> void:
|
||||
Guide.stop()
|
||||
if Hints.world == self:
|
||||
Hints.stop()
|
||||
Music.stop(self)
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what != NOTIFICATION_WM_CLOSE_REQUEST or menu_mode:
|
||||
|
||||
Reference in New Issue
Block a user