diff --git a/README.md b/README.md index 1090802..a937f5d 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,9 @@ On phones: tap to use a tool, drag to mark or move, two fingers to zoom and turn - **Rust**, compiled to **WebAssembly**, with [macroquad](https://github.com/not-fl3/macroquad) - one small module, no engine, no plugins. - A real 3D isometric world in pixel art: chunked meshes, flickering point lights, a glow pass, particles, billboard sprites with eight viewing directions, every frame of it drawn by code - **no image files** except the font. -- Procedural chiptune soundtrack as FastTracker 2 modules with its own XM replayer and synthesised sound effects. +- Procedural chiptune soundtrack as FastTracker 2 modules with its own XM replayer and synthesised sound effects: + 47 pieces around one leitmotif - in the dungeon dark and minor, above ground bright, with flute, harp, birdsong + and wind; the music follows the camera between the two worlds. - A tiny Rust savegame server (axum, Argon2 passwords) - optional; without it the game uses savegame files. - 18 languages: German, English, Spanish, Portuguese, Dutch, Danish, Norwegian, Swedish, Finnish, Russian, Croatian, Hungarian, Serbian, Romanian, Slovak, Czech, Italian and Greek – chosen from a diff --git a/assets/music/battle10.xm b/assets/music/battle10.xm new file mode 100644 index 0000000..7ff4c83 Binary files /dev/null and b/assets/music/battle10.xm differ diff --git a/assets/music/battle9.xm b/assets/music/battle9.xm new file mode 100644 index 0000000..3e00d8c Binary files /dev/null and b/assets/music/battle9.xm differ diff --git a/assets/music/build16.xm b/assets/music/build16.xm new file mode 100644 index 0000000..93e634e Binary files /dev/null and b/assets/music/build16.xm differ diff --git a/assets/music/build17.xm b/assets/music/build17.xm new file mode 100644 index 0000000..4797d47 Binary files /dev/null and b/assets/music/build17.xm differ diff --git a/assets/music/build18.xm b/assets/music/build18.xm new file mode 100644 index 0000000..e5bda58 Binary files /dev/null and b/assets/music/build18.xm differ diff --git a/assets/music/build19.xm b/assets/music/build19.xm new file mode 100644 index 0000000..7fa6ca1 Binary files /dev/null and b/assets/music/build19.xm differ diff --git a/assets/music/surface1.xm b/assets/music/surface1.xm new file mode 100644 index 0000000..d89aef2 Binary files /dev/null and b/assets/music/surface1.xm differ diff --git a/assets/music/surface2.xm b/assets/music/surface2.xm new file mode 100644 index 0000000..9258b10 Binary files /dev/null and b/assets/music/surface2.xm differ diff --git a/assets/music/surface3.xm b/assets/music/surface3.xm new file mode 100644 index 0000000..1c1f2c3 Binary files /dev/null and b/assets/music/surface3.xm differ diff --git a/assets/music/surface4.xm b/assets/music/surface4.xm new file mode 100644 index 0000000..30540eb Binary files /dev/null and b/assets/music/surface4.xm differ diff --git a/assets/music/surface_battle1.xm b/assets/music/surface_battle1.xm new file mode 100644 index 0000000..125bd2a Binary files /dev/null and b/assets/music/surface_battle1.xm differ diff --git a/assets/music/surface_battle2.xm b/assets/music/surface_battle2.xm new file mode 100644 index 0000000..27f365f Binary files /dev/null and b/assets/music/surface_battle2.xm differ diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c07362d..68d3fd7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -54,12 +54,17 @@ in hero waves from wave 4/6, buildable with a forge). The fortresses above groun fortresses (fortress.rs) in a clearing. Final missions: campaign M30 and co-op M20; free play, versus matches and editor random maps have the option "half above ground". -## 3b. Music (next milestone) +## 3b. Music – done (v202611-00039) Six more underground tracks and a block of six surface tracks: the same leitmotif brighter and in major, synthetic birdsong and wind, flute leads and harp arpeggios. The music follows the camera (surface or underground) with a crossfade. +Built in `tools/compose_music.py` (instruments flute, bird and wind; birds and wind on two extra channels only in +surface songs, so the older pieces stay byte for byte the same): build16-19, battle9-10, surface1-4, +surface_battle1-2; `audio::Category::Surface` / `SurfaceBattle`, chosen in `MusicDirector` when most of the +camera's surroundings lie above ground (4 s crossfade). + ## 4. Additions 1. **Random events** – earthquake (tunnels cave in), lava eruption, flood, gold vein found. Rare diff --git a/src/audio.rs b/src/audio.rs index 19fb01a..d0a4787 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -21,6 +21,8 @@ pub enum Category { Temple, Victory, Defeat, + Surface, // above ground: flute, harp, birdsong + SurfaceBattle, // fighting above ground } // indices of all tracks of a category, in play order @@ -221,7 +223,9 @@ mod tests { dc_audio_init(48000.0); let build = tracks_in(Category::Build); let battle = tracks_in(Category::Battle); - assert_eq!(build.len(), 15); + assert_eq!(build.len(), 19); + assert_eq!(tracks_in(Category::Surface).len(), 4); + assert_eq!(tracks_in(Category::SurfaceBattle).len(), 2); play_music(build[0], 0.0); let mut peak = 0.0f32; for k in 0..200 { diff --git a/src/main.rs b/src/main.rs index 7e7461f..56bd306 100644 --- a/src/main.rs +++ b/src/main.rs @@ -487,6 +487,19 @@ impl MusicDirector { } n >= 4 }; + // the camera looks at the land above ground: the music of the surface + let above = { + let (cx, cz) = (cam.0 as i32, cam.1 as i32); + let mut n = 0; + for y in (cz - 4..=cz + 4).step_by(2) { + for x in (cx - 4..=cx + 4).step_by(2) { + if map::inb(x, y) && g.map.sky(map::idx(x, y)) { + n += 1; + } + } + } + n >= 13 + }; let want = if title { C::Title } else if g.state == 1 { @@ -508,11 +521,24 @@ impl MusicDirector { } else { C::Build }; - let rotate = matches!(want, C::Build | C::Title) && self.current == Some(want) && audio::music_loops() >= 2; + let want = match want { + C::Battle if above => C::SurfaceBattle, + C::Build | C::Torture | C::Temple if above => C::Surface, + w => w, + }; + let rotate = matches!(want, C::Build | C::Title | C::Surface) && self.current == Some(want) && audio::music_loops() >= 2; if self.current != Some(want) || rotate || audio::current_track().is_none() { + let before = self.current; self.current = Some(want); let t = self.pick(want); - let fade = if matches!(want, C::Victory | C::Defeat) { 1.0 } else { 2.5 }; + // between the dungeon and the surface the music changes slowly + let fade = if matches!(want, C::Victory | C::Defeat) { + 1.0 + } else if matches!((before, want), (Some(C::Surface | C::SurfaceBattle), _) | (_, C::Surface | C::SurfaceBattle)) { + 4.0 + } else { + 2.5 + }; audio::play_music(t, fade); } } diff --git a/src/tracks.rs b/src/tracks.rs index f7acd17..7ec3b45 100644 --- a/src/tracks.rs +++ b/src/tracks.rs @@ -3,7 +3,7 @@ use crate::audio::Category; -pub const TRACKS: [(&str, Category, &[u8]); 35] = [ +pub const TRACKS: [(&str, Category, &[u8]); 47] = [ ("title", Category::Title, include_bytes!("../assets/music/title.xm")), ("title2", Category::Title, include_bytes!("../assets/music/title2.xm")), ("build1", Category::Build, include_bytes!("../assets/music/build1.xm")), @@ -28,6 +28,18 @@ pub const TRACKS: [(&str, Category, &[u8]); 35] = [ ("battle6", Category::Battle, include_bytes!("../assets/music/battle6.xm")), ("battle7", Category::Battle, include_bytes!("../assets/music/battle7.xm")), ("battle8", Category::Battle, include_bytes!("../assets/music/battle8.xm")), + ("build16", Category::Build, include_bytes!("../assets/music/build16.xm")), + ("build17", Category::Build, include_bytes!("../assets/music/build17.xm")), + ("build18", Category::Build, include_bytes!("../assets/music/build18.xm")), + ("build19", Category::Build, include_bytes!("../assets/music/build19.xm")), + ("battle9", Category::Battle, include_bytes!("../assets/music/battle9.xm")), + ("battle10", Category::Battle, include_bytes!("../assets/music/battle10.xm")), + ("surface1", Category::Surface, include_bytes!("../assets/music/surface1.xm")), + ("surface2", Category::Surface, include_bytes!("../assets/music/surface2.xm")), + ("surface3", Category::Surface, include_bytes!("../assets/music/surface3.xm")), + ("surface4", Category::Surface, include_bytes!("../assets/music/surface4.xm")), + ("surface_battle1", Category::SurfaceBattle, include_bytes!("../assets/music/surface_battle1.xm")), + ("surface_battle2", Category::SurfaceBattle, include_bytes!("../assets/music/surface_battle2.xm")), ("battle2", Category::Assault, include_bytes!("../assets/music/battle2.xm")), ("assault2", Category::Assault, include_bytes!("../assets/music/assault2.xm")), ("assault3", Category::Assault, include_bytes!("../assets/music/assault3.xm")), diff --git a/src/xm.rs b/src/xm.rs index fc68ad5..18d45a8 100644 --- a/src/xm.rs +++ b/src/xm.rs @@ -1130,7 +1130,7 @@ mod tests { fn render_all_songs() { for (k, (name, _, data)) in crate::tracks::TRACKS.iter().enumerate() { let m = Rc::new(load(data).expect("load")); - assert_eq!(m.channels, 8, "{}", name); + assert!(m.channels == 8 || (m.channels == 10 && name.starts_with("surface")), "{} {}", name, m.channels); let mut p = Player::new(m, 48000.0); let mut buf = vec![0.0f32; 48000 * 2]; let mut peak = 0.0f32; diff --git a/tools/compose_music.py b/tools/compose_music.py index e15b362..2f72786 100755 --- a/tools/compose_music.py +++ b/tools/compose_music.py @@ -258,6 +258,52 @@ def timpani(): return out +def flute_cycle(amp=0.5): + # soft and hollow: a sine with a little triangle and a quiet second harmonic + raw = [math.sin(2 * math.pi * i / CYCLE) + 0.18 * (4.0 * abs(i / CYCLE - 0.5) - 1.0) + 0.12 * math.sin(4 * math.pi * i / CYCLE) for i in range(CYCLE)] + m = max(abs(v) for v in raw) + return [amp * v / m for v in raw] + + +def bird(): + """A little songbird: three quick rising chirps and a falling trill (rendered for C-4).""" + out = [] + ph = 0.0 + phrases = [(2600, 3900, 0.06), (2700, 4100, 0.06), (2800, 4300, 0.05), (4200, 3000, 0.11)] + for k, (f0, f1, dur) in enumerate(phrases): + n = int(DRUM_RATE * dur) + for i in range(n): + t = i / n + f = f0 + (f1 - f0) * t + # a fast warble on the last one + if k == 3: + f += 250 * math.sin(2 * math.pi * 28 * i / DRUM_RATE) + ph += 2 * math.pi * f / DRUM_RATE + env = math.sin(math.pi * t) ** 0.6 + out.append(0.55 * env * math.sin(ph)) + out += [0.0] * int(DRUM_RATE * 0.035) + return out + + +def wind(): + """Soft wind: filtered noise that swells and fades (a seamless loop of two seconds).""" + rnd = random.Random(23) + n = int(DRUM_RATE * 2.0) + out, lp, lp2 = [], 0.0, 0.0 + for i in range(n): + x = rnd.random() * 2 - 1 + lp += 0.06 * (x - lp) + lp2 += 0.06 * (lp - lp2) + swell = 0.6 + 0.4 * math.sin(2 * math.pi * i / n) + out.append(2.2 * lp2 * swell) + # cross-fade the ends so the loop has no click + k = int(DRUM_RATE * 0.1) + for i in range(k): + a = i / k + out[i] = out[i] * a + out[n - k + i] * (1 - a) + return out[: n - k] + + def build_instruments(): return [ Instrument("Lead pulse 25%", Sample("pulse25", pulse(0.25), loop=(0, CYCLE), relnote=CYCLE_REL, finetune=CYCLE_FT), @@ -285,14 +331,21 @@ def build_instruments(): Instrument("Harp", Sample("harp", harp_cycle(), loop=(0, CYCLE), relnote=CYCLE_REL, finetune=CYCLE_FT), env=[(0, 64), (4, 44), (30, 12), (70, 0)], fadeout=2000), Instrument("Timpani", Sample("timpani", timpani(), relnote=12)), + Instrument("Flute", Sample("flute", flute_cycle(), loop=(0, CYCLE), relnote=CYCLE_REL, finetune=CYCLE_FT), + env=[(0, 0), (5, 58), (20, 48), (60, 44)], sustain=3, fadeout=1100, vib=(0, 40, 4, 11)), + Instrument("Bird", Sample("bird", bird(), relnote=12)), + Instrument("Wind", Sample("wind", wind(), loop=(0, len(wind())), relnote=12), + env=[(0, 0), (60, 48), (120, 40)], sustain=2, fadeout=200), ] LEAD, SQUARE, PAD, BASS, PLUCK, KICK, SNARE, HAT = range(1, 9) ORGAN, BELL, CHOIR, SUB, TOM, CRASH, HARP, TIMP = range(9, 17) -# channel layout -CH_LEAD, CH_COUNTER, CH_PAD1, CH_PAD2, CH_BASS, CH_ARP, CH_DRUM, CH_HAT = range(8) -CHANNELS = 8 +FLUTE, BIRD, WIND = range(17, 20) +# channel layout (the last two only for songs above ground: birds and wind) +CH_LEAD, CH_COUNTER, CH_PAD1, CH_PAD2, CH_BASS, CH_ARP, CH_DRUM, CH_HAT, CH_BIRD, CH_WIND = range(10) +CHANNELS = 10 +BASE_CHANNELS = 8 # songs that leave birds and wind out are written with 8 channels # ------------------------------------------------------------------ patterns @@ -661,7 +714,7 @@ def song_victory(): p0.bass(ch, "R...R...R...R...", vol=48) p0.drums(kick="x.......x.......", snare="xxxx............", sv=30) p0.set(63, CH_LEAD, KEY_OFF) - for c in range(CHANNELS): + for c in range(BASE_CHANNELS): p0.set(63, c, KEY_OFF) silence = Pattern(1) p = [s.add(x) for x in (p0, silence)] @@ -679,7 +732,7 @@ def song_defeat(): p0.pads(ch, vol=22, octave=3) p0.bass(ch, "R...............", vol=44, octave=1) p0.drums(kick="x...............", kv=44) - for c in range(CHANNELS): + for c in range(BASE_CHANNELS): p0.set(63, c, KEY_OFF) silence = Pattern(1) p = [s.add(x) for x in (p0, silence)] @@ -703,13 +756,14 @@ def write_xm(path, song, instruments): out += b"DungeonCraft compose".ljust(20, b" ") out += struct.pack("