extends SceneTree ## Sound and music (scripts/core/sfx.gd, scripts/core/music.gd): every sound ## and track loads; doing things makes the right sounds (chop, a tree ## falling, mining, crafting, placing, eating, getting hurt, fishing, ## footsteps on grass and on a wooden floor); the music follows the mood ## (day, night, storm, the red moon, wolves after you, the Old Stag), holds on ## a moment after danger; and the volumes are kept. Headless, so nothing is ## heard: Sfx logs what was asked for. ## Run: godot --headless --path . --script res://tests/test_audio.gd var _fails := 0 var _finished := false var A: Node var S: Node var SX: Node var M: Node var world: Node3D var player: CharacterBody3D 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: A = root.get_node("Actions") S = root.get_node("Saves") SX = root.get_node("Sfx") M = root.get_node("Music") SX.prefs_path = "user://test_audio.json" DirAccess.remove_absolute(SX.prefs_path) 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(SX.prefs_path) SX.load_prefs() print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) quit(1 if _fails > 0 else 0) func _heard(id: String, since: int) -> int: var n := 0 for i in range(since, SX.heard.size()): if SX.heard[i][0] == id: n += 1 return n func _node(kind: String) -> Node3D: var best: Node3D = null var bd := INF for n in world.props.resource_nodes.values(): if is_instance_valid(n) and n.kind == kind and not n.depleted: var d: float = n.global_position.distance_to(player.global_position) if d < bd: bd = d best = n return best func _hold(id: String) -> int: for i in range(player.inventory.size()): var s = player.inventory.get_slot(i) if s != null and s.id == id: var tmp = player.inventory.get_slot(0) player.inventory.set_slot(0, s) player.inventory.set_slot(i, tmp) player.select(0) return 0 return -1 func _body() -> void: print("the files") var bad: Array = [] for id in SX.SOUNDS: for f in SX.files(id): var st: Variant = load(f) if not (st is AudioStream) or (st as AudioStream).get_length() < 0.02: bad.append(f) check(bad.is_empty(), "every sound's files load (%d sounds) %s" % [SX.SOUNDS.size(), bad]) var lens: Array = [] for m in ["calm", "night", "storm", "danger", "boss"]: var st: Variant = load(M.DIR + m + ".ogg") lens.append(roundi(st.get_length()) if st is AudioStream else 0) check(lens.all(func(l): return l >= 40), "five music tracks, 40 s or more each %s" % [lens]) var amb := ["birds", "crickets", "fire", "owl_0", "owl_1"].filter(func(a): return load(M.AMB + a + ".ogg") is AudioStream) check(amb.size() == 5, "birds, crickets, the fire's crackle and two owl hoots") check(AudioServer.get_bus_index("Music") > 0 and AudioServer.get_bus_index("SFX") > 0 and AudioServer.get_bus_index("Ambience") > 0, "Music, SFX and Ambience buses") print("in the world") S.current_id = S.create_world("Sounds", 1337, {}) 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) player.survival.set_process(false) world.day_night.frozen = true world.weather.set_process(false) world.weather.force("clear") world.creatures.enabled = false if world.wolves != null: world.wolves.enabled = false var inv: Inventory = player.inventory inv.add("stone_axe", 1) inv.add("stone_pickaxe", 1) var h: int = SX.heard.size() var tree := _node("tree") _hold("stone_axe") player.global_position = tree.global_position + Vector3(1.2, 0, 0) var hits := 0 while not tree.depleted and hits < 40: player.survival.stamina = 100.0 if A.harvest_hit(player, tree, tree.global_position + Vector3(0, 1.0, 0)): hits += 1 check(_heard("chop", h) == hits - 1 and _heard("tree_fall", h) == 1, "an axe on a tree: chop x%d, then it falls" % (hits - 1)) h = SX.heard.size() var rock := _node("rock") _hold("stone_pickaxe") player.global_position = rock.global_position + Vector3(1.6, 0, 0) player.survival.stamina = 100.0 A.harvest_hit(player, rock, rock.global_position + Vector3(0, 0.5, 0)) check(_heard("mine", h) == 1, "a pickaxe on a boulder: mine") var at: Vector3 = SX.heard.back()[1] check(at is Vector3 and at.distance_to(rock.global_position) < 2.0, "heard where it happened") h = SX.heard.size() var rec: Dictionary = root.get_node("ItemDB").recipes["stone_axe"] for id in rec.inputs: inv.add(id, int(rec.inputs[id])) check(A.craft(player, "stone_axe") and _heard("craft", h) == 1, "crafting") inv.add("wood_floor", 1) var slot := _hold("wood_floor") var p0 := player.global_position + Vector3(4, 0, 0) p0.y = world.terrain.sample_height(p0.x, p0.z) var r: Dictionary = world.build.placement(ItemDB_get("wood_floor"), {"position": p0, "normal": Vector3.UP, "collider": world.terrain.get_node("TerrainBody")}, 0.0) check(r.ok and A.place(player, slot, r.xf, world) and _heard("place_wood", h) == 1, "placing a floor: a plank's knock") # (not a sound: building over plants, in any world, clears them) var plant: Node3D = null for pk in world.props.pickups.values(): if not pk.taken and pk.global_position.distance_to(player.global_position) < 40.0: plant = pk break var cover: Node3D = world.spawn_piece(ItemDB_get("wood_floor"), Transform3D(Basis(), plant.global_position + Vector3(0.3, 0.05, 0.2))) for _i in range(4): await physics_frame check(plant.taken and not plant.visible, "a floor built over a %s clears it (any hut, any world)" % plant.item_id) cover.queue_free() inv.add("berries", 3) player.survival.hunger = 40.0 A.eat(player, _hold("berries")) check(_heard("eat", h) == 1, "eating") player.survival.damage(5.0, player.global_position + Vector3(1, 0, 0), "a test") check(_heard("hurt", h) == 1, "getting hurt") SX.heard.clear() print("footsteps") check(player.step_sound() == "step_grass", "on the meadow: grass") var floor_piece: Node3D = null for pc in world.all_pieces(): if pc.item_id == "wood_floor": floor_piece = pc player.set_physics_process(true) player.global_position = floor_piece.global_position + Vector3(0, 0.6, 0) player.velocity = Vector3.ZERO for _i in range(20): await physics_frame check(player.step_sound() == "step_wood", "on the floor you built: wood") player.global_position = p0 + Vector3(8, 0.3, 0) for _i in range(10): await physics_frame player.set_physics_process(false) var steps0: int = SX.count("step_grass") player.velocity = Vector3(5, 0, 0) for _i in range(60): player._footsteps(1.0 / 60.0, false) if player.is_on_floor() else null player.velocity = Vector3.ZERO var steps: int = SX.count("step_grass") - steps0 check(steps >= 2 and steps <= 3, "walking 5 m: a step every stride (%d)" % steps) print("fishing") SX.heard.clear() inv.add("fishing_rod", 1) _hold("fishing_rod") player.survival.stamina = 100.0 A.cast_line(player, player.global_position + Vector3(0, 1.4, 0), Vector3(1, 0.2, 0), world) var bob: Node3D = world.bobber_of(player) check(SX.count("cast") == 1 and bob != null, "a cast") if bob != null: bob.state = "biting" bob.fish = "perch" A.reel(player, world) check(SX.count("reel") == 1 and SX.count("catch") == 1 and SX.count("ui_catch") == 1, "reeling a fish in: the reel, a splash, and a chime") print("creature voices") SX.heard.clear() var w2: Node3D = world.creatures.spawn("wolf", player.global_position + Vector3(9, 0, 0)) for _i in range(2): await physics_frame w2.set_physics_process(false) w2.state = "wander" w2._voice(0.1, "wander", 0.0) w2.state = "stalk" w2._voice(0.1, "wander", 0.0) check(SX.count("wolf_growl") == 1, "a wolf turns on you: it growls") for _i in range(20): w2._voice(0.5, "stalk", 0.0) check(SX.count("wolf_growl") >= 2 and SX.count("wolf_growl") <= 4, "and keeps growling now and then while it stalks (%d)" % SX.count("wolf_growl")) w2._attack_cd = 1.2 w2._voice(0.1, "stalk", 0.0) check(SX.count("wolf_snarl") == 1, "it lunges: a snarl") w2.take_hit(5.0, player.global_position) check(SX.count("wolf_yelp") == 1, "hit it: it yelps") w2.take_hit(5.0, player.global_position) check(SX.count("wolf_yelp") == 1, "(hit twice at once: one yelp)") w2.queue_free() var missing: Array = [] for k in w2.VOICES: for what in w2.VOICES[k]: if not SX.SOUNDS.has(String(w2.VOICES[k][what])): missing.append("%s.%s" % [k, what]) check(missing.is_empty(), "every creature's voice is a sound that exists %s" % [missing]) print("the music") M.start(world) check(M.decide() == "calm", "a clear morning: calm") world.day_night.time = 23.0 / 24.0 world.day_night.apply(true) check(M.decide() == "night", "after dark: night") world.day_night.day = 7 check(M.decide() == "storm", "the red wolf moon: the storm music") world.day_night.day = 5 world.weather.force("storm") world.day_night.time = 13.0 / 24.0 world.day_night.apply(true) check(M.decide() == "storm", "a storm by day: storm") world.weather.force("clear") var wolf: Node3D = world.creatures.spawn("wolf", player.global_position + Vector3(12, 0, 0)) for _i in range(3): await physics_frame wolf.set_physics_process(false) wolf.state = "wander" check(M.decide() == "calm", "a wolf wandering nearby: still calm") wolf.state = "stalk" M.step(0.6) check(M.mood == "danger", "the wolf comes for you: danger") wolf.queue_free() await process_frame for _i in range(10): M.step(0.5) check(M.mood == "danger", "it's gone, but the music holds on a moment") for _i in range(20): M.step(0.5) check(M.mood == "calm", "then calm again") var stag: Node3D = world.creatures.spawn("rabbit", player.global_position + Vector3(30, 0, 0)) for _i in range(2): await physics_frame stag.set_physics_process(false) stag.kind = "old_stag" M.step(0.6) check(M.mood == "boss", "the Old Stag is up: the boss music") stag.queue_free() M.stop(world) M.step(0.6) check(M.mood == "calm", "back at the title: calm") print("volumes") SX.set_volume("Music", 0.25) SX.set_volume("SFX", 0.0) var mb := AudioServer.get_bus_index("Music") check(absf(AudioServer.get_bus_volume_db(mb) - linear_to_db(0.25)) < 0.01, "Music at a quarter") check(AudioServer.is_bus_mute(AudioServer.get_bus_index("SFX")), "Sounds at zero: muted") SX.load_prefs() check(absf(float(SX.volumes.Music) - 0.25) < 0.001 and float(SX.volumes.SFX) == 0.0, "kept for next time") world.queue_free() await process_frame _finished = true func ItemDB_get(id: String) -> Dictionary: return root.get_node("ItemDB").get_item(id)