Add industrial metal soundtrack with seamless loop

- assets/audio/music/zombie_industrial_loop.ogg: 57.60 s Ogg Vorbis loop
  generated locally with YuE2-3B on the Tesla V100 (score written for this
  track; vocals removed with HTDemucs). The loop is spliced at the track's
  structural repeat with a 0.6 s crossfade: measured seam dip <= 1.6 dB.
- scripts/main.gd: "Music" AudioStreamPlayer at -11 dB, started at boot on
  desktop and on the start click on Web (browser autoplay policy). It forces
  stream.loop at runtime because the Web staging step re-imports the OGG with
  loop=false.
- tools/test_music.gd: soundtrack test (loop, length, volume, playback,
  still playing after player death).
- docs/MUSIC.md: source, loop construction, verification, licence.
- docs/WEB-DEPLOY.md: release ab6054111f5919a8 notes.

Initial commit of the project.
This commit is contained in:
enne2
2026-10-10 23:11:13 +02:00
commit 956a15a525
199 changed files with 4563 additions and 0 deletions
+221
View File
@@ -0,0 +1,221 @@
@tool
extends EditorImportPlugin
func _get_importer_name() -> String:
return "goldsrc.bsp"
func _get_visible_name() -> String:
return "GoldSrc BSP"
func _get_recognized_extensions() -> PackedStringArray:
return PackedStringArray(["bsp"])
func _get_save_extension() -> String:
return "scn"
func _get_resource_type() -> String:
return "PackedScene"
func _get_preset_count() -> int:
return 1
func _get_preset_name(preset_index: int) -> String:
return "Default"
func _get_priority() -> float:
return 1.0
func _get_import_order() -> int:
return 1 # Must run after MDL/SPR importers (order 0) so their scenes are available.
# Must run on main thread — ArrayMesh/ImageTexture serialization deadlocks
# when ResourceSaver.save() is called from import worker threads.
func _can_import_threaded() -> bool:
return false
func _get_import_options(_path: String, _preset_index: int) -> Array[Dictionary]:
return [
{
"name": "wad_directory",
"default_value": "",
"property_hint": PROPERTY_HINT_DIR,
"hint_string": "",
},
{
"name": "scale_factor",
"default_value": 0.025,
"property_hint": PROPERTY_HINT_RANGE,
"hint_string": "0.001,1.0,0.001",
},
# Base res:// path for resolving entity model/sprite references.
# Set this to the folder that mirrors your GoldSrc game root inside the
# project (e.g. res://res), so "models/headcrab.mdl" resolves correctly.
# Leave empty when your project root already mirrors the game root.
{
"name": "model_directory",
"default_value": "",
"property_hint": PROPERTY_HINT_DIR,
"hint_string": "",
},
]
func _get_option_visibility(_path: String, _option_name: StringName, _options: Dictionary) -> bool:
return true
func _import(source_file: String, save_path: String, options: Dictionary,
_platform_variants: Array[String], _gen_files: Array[String]) -> Error:
var wad_directory: String = options.get("wad_directory", "")
var scale_factor: float = options.get("scale_factor", 0.025)
var model_directory: String = options.get("model_directory", "")
var bsp := GoldSrcBSP.new()
bsp.set_scale_factor(scale_factor)
bsp.load_bsp(source_file)
# Resolve WAD files. If an explicit directory is given, scan it; otherwise
# read the WADs the BSP itself declares in worldspawn's "wad" key and look
# them up next to the .bsp and at the project root — so a stock project
# textures correctly with no per-file import config.
if wad_directory != "":
var dir := DirAccess.open(wad_directory)
if dir:
dir.list_dir_begin()
var file_name := dir.get_next()
while file_name != "":
if not dir.current_is_dir() and file_name.get_extension().to_lower() == "wad":
var wad := GoldSrcWAD.new()
var wad_path := wad_directory.path_join(file_name)
wad.load_wad(wad_path)
bsp.add_wad(wad)
file_name = dir.get_next()
dir.list_dir_end()
else:
push_warning("BSP Importer: Could not open WAD directory '%s'" % wad_directory)
else:
_load_declared_wads(bsp, source_file)
bsp.build_mesh()
# Reparent children to a plain Node3D so the saved scene is GDExtension-independent
var root := Node3D.new()
var root_name := source_file.get_file().get_basename()
if root_name.is_empty():
root_name = "bsp_root"
root.name = root_name
var children := bsp.get_children()
for child in children:
bsp.remove_child(child)
root.add_child(child)
_instantiate_entity_models(root, model_directory)
# Bake the stripped BSP (PLANES/NODES/CLIPNODES/LEAFS/VISIBILITY/MODELS) so
# VisibilityManager (PVS) and hull collision can both initialise from the .scn
# without the original .bsp. Collision bodies carry only a "bsp_model" index
# and walk up the tree to find this. build_mesh() already parked it on the BSP
# node, so read it back rather than rebuilding the whole payload —
# PackedByteArray is copy-on-write, so this is a refcount.
var bsp_blob: PackedByteArray = bsp.get_meta("bsp_data", PackedByteArray())
if not bsp_blob.is_empty():
root.set_meta("bsp_data", bsp_blob)
_set_owner_recursive(root, root)
var scene := PackedScene.new()
var pack_err := scene.pack(root)
if pack_err != OK:
push_error("BSP Importer: Failed to pack scene: %s" % error_string(pack_err))
return pack_err
var save_err := ResourceSaver.save(scene, save_path + "." + _get_save_extension())
return save_err
# Load the WADs a BSP declares in worldspawn's "wad" key (a ';'-separated list
# of GoldSrc paths). Only the basename is used; each is searched for beside the
# .bsp first, then at the project root. This mirrors the runtime WAD lookup so
# the editor and the game texture maps identically without any import options.
func _load_declared_wads(bsp: GoldSrcBSP, source_file: String) -> void:
var wad_string := ""
for ent in bsp.get_entities():
if ent.get("classname", "") == "worldspawn":
wad_string = ent.get("wad", "")
break
var search_dirs := [source_file.get_base_dir(), "res://"]
var seen := {}
for wad_path in wad_string.split(";", false):
var wad_name := wad_path.get_file().to_lower()
if wad_name.is_empty() or seen.has(wad_name):
continue
seen[wad_name] = true
for base in search_dirs:
var full_path: String = base.path_join(wad_name)
if FileAccess.file_exists(full_path):
var wad := GoldSrcWAD.new()
if wad.load_wad(full_path) == OK:
bsp.add_wad(wad)
break
func _set_owner_recursive(node: Node, owner: Node) -> void:
for child in node.get_children():
child.owner = owner
# Don't recurse into instanced sub-scenes — their internal nodes belong
# to that scene, not to this one. Setting owner on the root is enough.
if child.scene_file_path == "":
_set_owner_recursive(child, owner)
# Instantiate MDL/SPR scenes for each point entity that has a "model" key.
# Resolves entity paths relative to model_dir (if set) or res:// (if empty),
# mirroring the GoldSrc layout at that root.
# Falls back to a placeholder box if the asset is not found.
func _instantiate_entity_models(root: Node3D, model_dir: String) -> void:
var base := model_dir if model_dir != "" else "res://"
for child in root.get_children():
if not child.has_meta("entity"):
continue
var ent: Dictionary = child.get_meta("entity")
var model_path: String = ent.get("model", "")
# Brush entities reference internal BSP sub-models ("*N") — skip them.
if model_path.is_empty() or model_path.begins_with("*"):
continue
var res_path := base.path_join(model_path)
var instance: Node3D = null
if ResourceLoader.exists(res_path):
var scene := load(res_path) as PackedScene
if scene:
instance = scene.instantiate() as Node3D
if instance == null:
var mi := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = Vector3(0.3, 0.3, 0.3)
mi.mesh = box
instance = mi
push_warning("BSP Importer: model not found, using placeholder for '%s'" % res_path)
var node_name := model_path.get_file().get_basename()
if node_name.is_empty():
node_name = "unnamed_entity_model"
instance.name = node_name
child.add_child(instance)