feat(audio): ✨ add procedural audio preview synth
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
b54f320703
commit
b1febc5884
5 changed files with 357 additions and 13 deletions
280
.project/designs/app/src/audioSynth.ts
Normal file
280
.project/designs/app/src/audioSynth.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
// Procedural-preview synthesiser for the audio design page.
|
||||
//
|
||||
// This is a design-app feature, NOT a substitute for the production audio
|
||||
// path. The in-game `AudioManager` autoload loads real .ogg files via
|
||||
// `AudioStreamPlayer` — that pipeline is owned by `audio_manager.gd` and
|
||||
// is unchanged by anything here.
|
||||
//
|
||||
// What this module does: render category-appropriate Web Audio API tones
|
||||
// in the browser so a designer reading the audio design page can hear
|
||||
// what each manifest key will *sound like* before the launch pack's .ogg
|
||||
// files are sourced (objective p2-16). Recipes hint at the dwarven /
|
||||
// forge / mountain aesthetic — low brass for war horns, anvil pings for
|
||||
// forge events, deep cavern rumbles for apex roars.
|
||||
//
|
||||
// Each manifest key maps to a "recipe": which oscillator(s), which
|
||||
// envelope, optional noise burst, optional pitch sweep. When a key has
|
||||
// no explicit recipe, the synth picks one by the key's prefix
|
||||
// (unit / building / fauna / weather / ui), so newly-added manifest
|
||||
// keys still produce an audible preview without a code change.
|
||||
|
||||
let cachedCtx: AudioContext | null = null;
|
||||
|
||||
function getContext(): AudioContext {
|
||||
if (cachedCtx == null) {
|
||||
type WindowWithWebkit = Window & { webkitAudioContext?: typeof AudioContext };
|
||||
const w = window as WindowWithWebkit;
|
||||
const Ctor: typeof AudioContext = window.AudioContext ?? w.webkitAudioContext!;
|
||||
cachedCtx = new Ctor();
|
||||
}
|
||||
// Most browsers suspend the context until a user gesture. Resume on
|
||||
// every play call — cheap, idempotent, and handles the first click.
|
||||
if (cachedCtx.state === "suspended") {
|
||||
cachedCtx.resume();
|
||||
}
|
||||
return cachedCtx;
|
||||
}
|
||||
|
||||
// ─── Oscillator + noise builders ─────────────────────────────────────────
|
||||
|
||||
interface Tone {
|
||||
type: OscillatorType;
|
||||
freq: number;
|
||||
/** Optional sweep target frequency reached over `dur`. */
|
||||
freqEnd?: number;
|
||||
/** Detune in cents. */
|
||||
detune?: number;
|
||||
}
|
||||
|
||||
interface Recipe {
|
||||
/** Tones layered together. */
|
||||
tones?: Tone[];
|
||||
/** Pink/white-noise burst layered with the tones. */
|
||||
noise?: { kind: "white" | "pink"; cutoff?: number; resonance?: number };
|
||||
/** Total length in seconds. */
|
||||
dur: number;
|
||||
/** Peak gain (0..1). */
|
||||
gain: number;
|
||||
/** Attack in seconds (default 0.005). */
|
||||
attack?: number;
|
||||
/** Decay shape: 'exp' (default) or 'linear'. */
|
||||
decay?: "exp" | "linear";
|
||||
/** Optional tremolo LFO depth (0..1). */
|
||||
tremolo?: { rate: number; depth: number };
|
||||
/** Lowpass cutoff applied to the whole recipe. */
|
||||
lowpass?: number;
|
||||
/** Highpass cutoff applied to the whole recipe. */
|
||||
highpass?: number;
|
||||
}
|
||||
|
||||
function makeNoiseBuffer(c: AudioContext, kind: "white" | "pink", dur: number): AudioBuffer {
|
||||
const len = Math.max(1, Math.floor(c.sampleRate * dur));
|
||||
const buf = c.createBuffer(1, len, c.sampleRate);
|
||||
const data = buf.getChannelData(0);
|
||||
if (kind === "white") {
|
||||
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
|
||||
} else {
|
||||
// Voss-McCartney pink-noise approximation.
|
||||
let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const w = Math.random() * 2 - 1;
|
||||
b0 = 0.99886 * b0 + w * 0.0555179;
|
||||
b1 = 0.99332 * b1 + w * 0.0750759;
|
||||
b2 = 0.96900 * b2 + w * 0.1538520;
|
||||
b3 = 0.86650 * b3 + w * 0.3104856;
|
||||
b4 = 0.55000 * b4 + w * 0.5329522;
|
||||
b5 = -0.7616 * b5 - w * 0.0168980;
|
||||
data[i] = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + w * 0.5362) * 0.115;
|
||||
b6 = w * 0.115;
|
||||
}
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
export function play(key: string): void {
|
||||
const recipe = recipeFor(key);
|
||||
const c = getContext();
|
||||
const t0 = c.currentTime;
|
||||
const dur = recipe.dur;
|
||||
const attack = recipe.attack ?? 0.005;
|
||||
|
||||
const masterGain = c.createGain();
|
||||
masterGain.gain.setValueAtTime(0, t0);
|
||||
masterGain.gain.linearRampToValueAtTime(recipe.gain, t0 + attack);
|
||||
if ((recipe.decay ?? "exp") === "exp") {
|
||||
masterGain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||
} else {
|
||||
masterGain.gain.linearRampToValueAtTime(0, t0 + dur);
|
||||
}
|
||||
|
||||
// Optional tone-shaping filters between the layered sources and master.
|
||||
let head: AudioNode = masterGain;
|
||||
if (recipe.lowpass != null) {
|
||||
const lp = c.createBiquadFilter();
|
||||
lp.type = "lowpass";
|
||||
lp.frequency.setValueAtTime(recipe.lowpass, t0);
|
||||
lp.connect(masterGain);
|
||||
head = lp;
|
||||
}
|
||||
if (recipe.highpass != null) {
|
||||
const hp = c.createBiquadFilter();
|
||||
hp.type = "highpass";
|
||||
hp.frequency.setValueAtTime(recipe.highpass, t0);
|
||||
hp.connect(head);
|
||||
head = hp;
|
||||
}
|
||||
|
||||
// Tones
|
||||
for (const tone of recipe.tones ?? []) {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = tone.type;
|
||||
osc.frequency.setValueAtTime(tone.freq, t0);
|
||||
if (tone.freqEnd != null) {
|
||||
osc.frequency.exponentialRampToValueAtTime(
|
||||
Math.max(20, tone.freqEnd),
|
||||
t0 + dur,
|
||||
);
|
||||
}
|
||||
if (tone.detune != null) osc.detune.setValueAtTime(tone.detune, t0);
|
||||
const mix = c.createGain();
|
||||
mix.gain.value = 1 / Math.max(1, recipe.tones?.length ?? 1);
|
||||
if (recipe.tremolo) {
|
||||
const lfo = c.createOscillator();
|
||||
lfo.frequency.value = recipe.tremolo.rate;
|
||||
const lfoGain = c.createGain();
|
||||
lfoGain.gain.value = recipe.tremolo.depth;
|
||||
lfo.connect(lfoGain).connect(mix.gain);
|
||||
lfo.start(t0);
|
||||
lfo.stop(t0 + dur);
|
||||
}
|
||||
osc.connect(mix).connect(head);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + dur);
|
||||
}
|
||||
|
||||
// Noise burst
|
||||
if (recipe.noise) {
|
||||
const buf = makeNoiseBuffer(c, recipe.noise.kind, dur);
|
||||
const src = c.createBufferSource();
|
||||
src.buffer = buf;
|
||||
let chain: AudioNode = src;
|
||||
if (recipe.noise.cutoff != null) {
|
||||
const f = c.createBiquadFilter();
|
||||
f.type = "lowpass";
|
||||
f.frequency.setValueAtTime(recipe.noise.cutoff, t0);
|
||||
if (recipe.noise.resonance != null) f.Q.setValueAtTime(recipe.noise.resonance, t0);
|
||||
chain.connect(f);
|
||||
chain = f;
|
||||
}
|
||||
const noiseGain = c.createGain();
|
||||
noiseGain.gain.value = 0.6;
|
||||
chain.connect(noiseGain).connect(head);
|
||||
src.start(t0);
|
||||
src.stop(t0 + dur);
|
||||
}
|
||||
|
||||
masterGain.connect(c.destination);
|
||||
}
|
||||
|
||||
// ─── Recipe library ──────────────────────────────────────────────────────
|
||||
|
||||
const RECIPES: Record<string, Recipe> = {
|
||||
// ── UI ────────────────────────────────────────────────────────────────
|
||||
"ui_click": { tones: [{ type: "square", freq: 1200, freqEnd: 800 }], dur: 0.06, gain: 0.18 },
|
||||
"ui_hover": { tones: [{ type: "sine", freq: 1400 }], dur: 0.05, gain: 0.10 },
|
||||
"menu_open": { tones: [{ type: "sawtooth", freq: 200, freqEnd: 120 }], dur: 0.40, gain: 0.18, lowpass: 1200 },
|
||||
"notification": { tones: [{ type: "sine", freq: 660 }, { type: "sine", freq: 880 }], dur: 0.40, gain: 0.18, attack: 0.02 },
|
||||
"error": { tones: [{ type: "square", freq: 200, freqEnd: 120 }], dur: 0.18, gain: 0.20 },
|
||||
|
||||
// ── Turn cycle ────────────────────────────────────────────────────────
|
||||
"turn_started": { tones: [{ type: "sawtooth", freq: 220, freqEnd: 330 }, { type: "sawtooth", freq: 110 }], dur: 0.50, gain: 0.22, attack: 0.02, lowpass: 2000 },
|
||||
"turn_ended": { noise: { kind: "pink", cutoff: 400 }, dur: 0.20, gain: 0.30 },
|
||||
|
||||
// ── City ──────────────────────────────────────────────────────────────
|
||||
"city_founded": { tones: [{ type: "sine", freq: 660 }, { type: "sine", freq: 880 }, { type: "sine", freq: 1320 }], dur: 1.40, gain: 0.20, attack: 0.01 },
|
||||
"city_grew": { tones: [{ type: "sine", freq: 800 }, { type: "sine", freq: 1200 }], dur: 0.70, gain: 0.18, attack: 0.02 },
|
||||
"city_starved": { noise: { kind: "pink", cutoff: 600 }, dur: 0.90, gain: 0.18, attack: 0.05 },
|
||||
"border_expanded":{ tones: [{ type: "sawtooth", freq: 220, freqEnd: 330 }], dur: 0.45, gain: 0.16, attack: 0.04, lowpass: 1500 },
|
||||
|
||||
// ── Combat ────────────────────────────────────────────────────────────
|
||||
"combat_started":{ tones: [{ type: "sawtooth", freq: 110, freqEnd: 165 }, { type: "sawtooth", freq: 220 }], dur: 0.80, gain: 0.25, attack: 0.05, lowpass: 1500 },
|
||||
"combat_hit": { tones: [{ type: "triangle", freq: 220 }], noise: { kind: "white", cutoff: 600 }, dur: 0.08, gain: 0.30 },
|
||||
"unit_killed": { tones: [{ type: "sine", freq: 60 }], noise: { kind: "white", cutoff: 1200 }, dur: 0.18, gain: 0.30 },
|
||||
"unit_promoted": { tones: [{ type: "sawtooth", freq: 330, freqEnd: 440 }, { type: "sawtooth", freq: 440 }], dur: 0.30, gain: 0.20, lowpass: 2500 },
|
||||
|
||||
// ── Units (categorical) ───────────────────────────────────────────────
|
||||
"unit.melee.attack": { noise: { kind: "white", cutoff: 1500 }, tones: [{ type: "triangle", freq: 200, freqEnd: 80 }], dur: 0.16, gain: 0.30 },
|
||||
"unit.melee.hit": { noise: { kind: "white", cutoff: 800 }, tones: [{ type: "triangle", freq: 1200, freqEnd: 600 }], dur: 0.10, gain: 0.30 },
|
||||
"unit.melee.death": { tones: [{ type: "sine", freq: 80 }], noise: { kind: "white", cutoff: 800 }, dur: 0.22, gain: 0.32 },
|
||||
"unit.ranged.attack": { noise: { kind: "white", cutoff: 2500 }, dur: 0.10, gain: 0.30 },
|
||||
"unit.ranged.hit": { tones: [{ type: "triangle", freq: 200, freqEnd: 100 }], dur: 0.07, gain: 0.30 },
|
||||
"unit.ranged.death": { tones: [{ type: "sine", freq: 100 }], noise: { kind: "white", cutoff: 800 }, dur: 0.18, gain: 0.30 },
|
||||
"unit.siege.attack": { tones: [{ type: "sine", freq: 50 }], noise: { kind: "pink", cutoff: 400 }, dur: 0.45, gain: 0.40, attack: 0.005 },
|
||||
"unit.civilian.death":{ tones: [{ type: "sine", freq: 200, freqEnd: 80 }], dur: 0.25, gain: 0.20 },
|
||||
"unit_moved": { noise: { kind: "white", cutoff: 800 }, dur: 0.04, gain: 0.10 },
|
||||
|
||||
// ── Buildings ─────────────────────────────────────────────────────────
|
||||
"building.civic.complete": { tones: [{ type: "sine", freq: 440 }, { type: "sine", freq: 660 }], dur: 1.00, gain: 0.22, attack: 0.01 },
|
||||
"building.production.complete": { tones: [{ type: "triangle", freq: 1200, freqEnd: 800 }], dur: 0.50, gain: 0.30, attack: 0.005 },
|
||||
"building.military.complete": { noise: { kind: "pink", cutoff: 600 }, tones: [{ type: "sawtooth", freq: 150 }], dur: 0.80, gain: 0.30, attack: 0.02 },
|
||||
"building.defense.complete": { tones: [{ type: "sine", freq: 80 }], noise: { kind: "white", cutoff: 500 }, dur: 0.20, gain: 0.30 },
|
||||
"wonder_built": { tones: [{ type: "sine", freq: 440 }, { type: "sine", freq: 554 }, { type: "sine", freq: 659 }, { type: "sine", freq: 880 }], dur: 1.80, gain: 0.22, attack: 0.10 },
|
||||
|
||||
// ── Research ──────────────────────────────────────────────────────────
|
||||
"tech_researched": { tones: [{ type: "sine", freq: 1320 }, { type: "sine", freq: 1760 }], dur: 0.70, gain: 0.20, attack: 0.005 },
|
||||
"culture_researched":{ noise: { kind: "pink", cutoff: 800 }, tones: [{ type: "sine", freq: 220 }], dur: 0.70, gain: 0.22 },
|
||||
"research_start": { tones: [{ type: "square", freq: 1500 }], dur: 0.04, gain: 0.10 },
|
||||
|
||||
// ── Era / golden age / victory ────────────────────────────────────────
|
||||
"era_advanced": { noise: { kind: "pink", cutoff: 250 }, tones: [{ type: "sine", freq: 60 }], dur: 1.40, gain: 0.30, attack: 0.05 },
|
||||
"golden_age_swell": { tones: [{ type: "sawtooth", freq: 220, freqEnd: 440 }, { type: "sawtooth", freq: 110, freqEnd: 220 }], dur: 1.80, gain: 0.25, attack: 0.40, lowpass: 1500 },
|
||||
"victory_fanfare": { tones: [{ type: "sawtooth", freq: 440 }, { type: "sawtooth", freq: 554 }, { type: "sawtooth", freq: 659 }, { type: "sawtooth", freq: 880 }], dur: 1.40, gain: 0.25, attack: 0.05, lowpass: 2500 },
|
||||
|
||||
// ── Fauna ─────────────────────────────────────────────────────────────
|
||||
"fauna.predator.spawn": { noise: { kind: "white", cutoff: 1500 }, dur: 0.20, gain: 0.18 },
|
||||
"fauna.predator.attack": { tones: [{ type: "sawtooth", freq: 200, freqEnd: 150 }], noise: { kind: "white", cutoff: 800 }, dur: 0.30, gain: 0.30, tremolo: { rate: 25, depth: 0.5 } },
|
||||
"fauna.predator.hit": { tones: [{ type: "sawtooth", freq: 600 }], dur: 0.12, gain: 0.25 },
|
||||
"fauna.predator.death": { tones: [{ type: "sine", freq: 200, freqEnd: 70 }], dur: 0.40, gain: 0.25 },
|
||||
"fauna.apex_predator.spawn": { tones: [{ type: "sawtooth", freq: 60, freqEnd: 80 }, { type: "sawtooth", freq: 40 }], dur: 0.90, gain: 0.40, attack: 0.10, lowpass: 600, tremolo: { rate: 6, depth: 0.4 } },
|
||||
"fauna.apex_predator.attack": { tones: [{ type: "sawtooth", freq: 80 }], noise: { kind: "pink", cutoff: 400 }, dur: 0.50, gain: 0.40, tremolo: { rate: 12, depth: 0.5 } },
|
||||
"fauna.apex_predator.death": { tones: [{ type: "sine", freq: 80, freqEnd: 30 }], dur: 0.70, gain: 0.32, attack: 0.02 },
|
||||
"fauna.herbivore.spawn": { tones: [{ type: "sawtooth", freq: 400, freqEnd: 320 }], dur: 0.20, gain: 0.16, lowpass: 2000 },
|
||||
"fauna.herbivore.death": { tones: [{ type: "sine", freq: 400, freqEnd: 200 }], dur: 0.24, gain: 0.20 },
|
||||
"wild_spawn": { noise: { kind: "white", cutoff: 2000 }, dur: 0.18, gain: 0.18, highpass: 800 },
|
||||
|
||||
// ── Weather ───────────────────────────────────────────────────────────
|
||||
"weather.storm": { noise: { kind: "pink", cutoff: 200 }, tones: [{ type: "sine", freq: 60 }], dur: 1.00, gain: 0.25 },
|
||||
"weather.blizzard": { noise: { kind: "white", cutoff: 5000 }, dur: 1.20, gain: 0.22, highpass: 1500 },
|
||||
"weather.heat_wave": { tones: [{ type: "sawtooth", freq: 500 }], dur: 1.00, gain: 0.18, tremolo: { rate: 14, depth: 0.6 } },
|
||||
"weather.drought": { noise: { kind: "white", cutoff: 3000 }, dur: 0.90, gain: 0.16, highpass: 1500 },
|
||||
"weather.tornado": { noise: { kind: "pink", cutoff: 600 }, tones: [{ type: "sawtooth", freq: 80, freqEnd: 200 }], dur: 1.00, gain: 0.30, lowpass: 800 },
|
||||
"weather.hurricane": { noise: { kind: "pink", cutoff: 400 }, tones: [{ type: "sine", freq: 50 }], dur: 1.40, gain: 0.30 },
|
||||
};
|
||||
|
||||
// Generic recipes used when an explicit one isn't authored. Picked by
|
||||
// matching the manifest key's prefix.
|
||||
const PREFIX_RECIPES: { prefix: string; recipe: Recipe }[] = [
|
||||
{ prefix: "unit.", recipe: { tones: [{ type: "triangle", freq: 200 }], dur: 0.10, gain: 0.20 } },
|
||||
{ prefix: "building.", recipe: { tones: [{ type: "sine", freq: 440 }], dur: 0.50, gain: 0.20 } },
|
||||
{ prefix: "fauna.", recipe: { tones: [{ type: "sawtooth", freq: 150 }], dur: 0.25, gain: 0.22 } },
|
||||
{ prefix: "weather.", recipe: { noise: { kind: "pink", cutoff: 600 }, dur: 0.80, gain: 0.20 } },
|
||||
{ prefix: "ui_", recipe: { tones: [{ type: "square", freq: 800 }], dur: 0.05, gain: 0.15 } },
|
||||
];
|
||||
|
||||
const SILENT_RECIPE: Recipe = { dur: 0.15, gain: 0.0001 };
|
||||
|
||||
function recipeFor(key: string): Recipe {
|
||||
if (key === "_silent") return SILENT_RECIPE;
|
||||
const explicit = RECIPES[key];
|
||||
if (explicit) return explicit;
|
||||
for (const { prefix, recipe } of PREFIX_RECIPES) {
|
||||
if (key.startsWith(prefix)) return recipe;
|
||||
}
|
||||
return { tones: [{ type: "sine", freq: 600 }], dur: 0.15, gain: 0.18 };
|
||||
}
|
||||
|
||||
/** True if there's an explicit recipe for the key. Useful for badging in
|
||||
* the UI: synth ✓ vs synth (generic). */
|
||||
export function hasExplicitRecipe(key: string): boolean {
|
||||
return key in RECIPES;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
|||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import audioManifest from "@game-data/audio.json";
|
||||
import { play as playSynth, hasExplicitRecipe } from "../audioSynth";
|
||||
import { t } from "../theme";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
|
@ -262,7 +263,7 @@ const EntryRow = styled.div<{ $open: boolean }>`
|
|||
|
||||
const EntryHeader = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
grid-template-columns: auto 1fr auto auto auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 14px;
|
||||
|
|
@ -288,6 +289,35 @@ const Slider = styled.input.attrs({ type: "range" })`
|
|||
width: 240px;
|
||||
`;
|
||||
|
||||
const PlayBtn = styled.button`
|
||||
background: ${t.bg.btnNormal};
|
||||
color: ${t.text.btn};
|
||||
border: 1px solid ${t.border.panel};
|
||||
border-radius: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
&:hover {
|
||||
background: ${t.bg.btnHover};
|
||||
border-color: ${t.border.focus};
|
||||
color: ${t.text.btnHover};
|
||||
}
|
||||
&:active {
|
||||
background: ${t.bg.btnPressed};
|
||||
color: ${t.text.btnPressed};
|
||||
}
|
||||
`;
|
||||
|
||||
const PlayBtnInline = styled(PlayBtn)`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
const SliderRow = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr 90px;
|
||||
|
|
@ -365,6 +395,14 @@ function ResolutionPlayground(): React.ReactElement {
|
|||
<HopNote>
|
||||
{hit ? "✓ winner" : present ? "(would match)" : "miss"}
|
||||
</HopNote>
|
||||
{present && (
|
||||
<PlayBtnInline
|
||||
onClick={() => playSynth(key)}
|
||||
title={`play synthesised preview of ${key}`}
|
||||
>
|
||||
▶
|
||||
</PlayBtnInline>
|
||||
)}
|
||||
</Hop>
|
||||
);
|
||||
})}
|
||||
|
|
@ -387,6 +425,14 @@ function ResolutionPlayground(): React.ReactElement {
|
|||
? `fallback → ${manifest.sfx[key].fallback}`
|
||||
: "no fallback declared"}
|
||||
</HopNote>
|
||||
{key !== "_silent" && (
|
||||
<PlayBtnInline
|
||||
onClick={() => playSynth(key)}
|
||||
title={`play synthesised preview of ${key}`}
|
||||
>
|
||||
▶
|
||||
</PlayBtnInline>
|
||||
)}
|
||||
</Hop>
|
||||
))}
|
||||
</>
|
||||
|
|
@ -445,7 +491,24 @@ function ManifestBrowser(): React.ReactElement {
|
|||
onClick={() => setOpenKey(open ? null : key)}
|
||||
>
|
||||
<EntryHeader>
|
||||
<EntryKey>{key}</EntryKey>
|
||||
<PlayBtnInline
|
||||
onClick={(e) => { e.stopPropagation(); playSynth(key); }}
|
||||
title={
|
||||
hasExplicitRecipe(key)
|
||||
? `play synthesised preview of ${key}`
|
||||
: `play generic preview (no explicit recipe for ${key} yet)`
|
||||
}
|
||||
>
|
||||
▶
|
||||
</PlayBtnInline>
|
||||
<EntryKey>
|
||||
{key}
|
||||
{!hasExplicitRecipe(key) && key !== "_silent" && (
|
||||
<span style={{ color: t.text.disabled, marginLeft: 8, fontSize: 10 }}>
|
||||
(generic preview)
|
||||
</span>
|
||||
)}
|
||||
</EntryKey>
|
||||
<Badge $color={t.bg.deepest}>
|
||||
{variantCount === 0 ? "_silent" : `${variantCount} stream${variantCount > 1 ? "s" : ""}`}
|
||||
</Badge>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"generated_at": "2026-04-27T08:19:53Z",
|
||||
"generated_at": "2026-04-27T08:35:27Z",
|
||||
"totals": {
|
||||
"done": 103,
|
||||
"in_progress": 1,
|
||||
|
|
|
|||
|
|
@ -51,10 +51,10 @@ no horses, no carrier birds, no Earth-styled industrial telecom).
|
|||
| Era | Unit | Tech path | Culture path |
|
||||
|---|---|---|---|
|
||||
| era_2 | Foot Runner | `messenger_hut` (NEW, era_2 hub) + tech `tracking` | — (no era_2 culture building fits) |
|
||||
| era_3 | Tunnel Runner | `messenger_hut` + tech `tunnel_paths` (NEW) | extend existing `gathering_hall` (era_3 culture, `bardic_lore`) with `enables_units` |
|
||||
| era_4 | Rune Scribe | `messenger_hut`/`hold_post` + tech `runelore` | extend existing `chronicle_hall` (era_4 culture, `chronicle_keeping`) |
|
||||
| era_5 | Hold Courier | `hold_post` (NEW, era_5 hub upgrade) + tech `dwarf_heritage` | — (era_5 culture buildings = `temple_of_the_ancestor`, no flavor fit) |
|
||||
| era_6 | Beacon Bearer | `hold_post` + tech `beacon_chain` (NEW); ALSO requires Beacon Tower improvement on the route map | extend existing `chronicle_tower` (era_6 culture, `cultural_canon`) |
|
||||
| era_3 | Tunnel Runner | `messenger_hut` + tech `tunnel_paths` (NEW) | extend existing `gathering_hall` (tier 2, normal building, `bardic_lore` unlock) ✓ |
|
||||
| era_4 | Rune Scribe | `messenger_hut`/`hold_post` + tech `runelore` | — (referenced `chronicle_hall` is a phantom — culture tech `chronicle_keeping` claims to unlock it but no building file exists; pre-existing data bug, separate from p3-01) |
|
||||
| era_5 | Hold Courier | `hold_post` (NEW, era_5 hub upgrade) + tech `dwarf_heritage` | — (every era_5+ culture-tech-unlocked building is `wonder_type: "world"`; wonders are not utility hubs) |
|
||||
| era_6 | Beacon Bearer | `hold_post` + tech `beacon_chain` (NEW); ALSO requires Beacon Tower improvement on the route map | — (initial pick `chronicle_tower` is a tier-8 World Wonder, +35% empire culture, cost 620 — wrong category for a courier hub) |
|
||||
| era_7 | Steam Messenger | `steam_forgery_annex` (NEW, era_7 hub upgrade) + tech `steam_forging` | — (steam-mech is tech-path-native; no culture parallel) |
|
||||
| era_8 | Resonance Telegrapher | `resonance_chamber` (NEW, era_8 hub upgrade) + tech `rune_resonance` (NEW) | — (rune-resonance is tech-path-native; no culture parallel) |
|
||||
| era_9 | Hold-Network Warden | `hold_network_citadel` (NEW, era_9 hub upgrade) + tech `combined_arms` | — (era_9 culture-tech unlocks `testament_of_kings` which is itself a wonder, not a normal hub) |
|
||||
|
|
@ -67,10 +67,12 @@ no horses, no carrier birds, no Earth-styled industrial telecom).
|
|||
- `rune_scribe_hall.json` — DELETE. Era_4 Rune Scribe gates on `messenger_hut`/`hold_post` (tech path) + `chronicle_hall` extension (culture path).
|
||||
- `beacon_tower.json` — MOVE from `data/buildings/` to `improvements/`. Beacon Tower is a killable hilltop structure built by an engineer unit on a hex tile, not a city building. Same category as the existing `fort` improvement.
|
||||
|
||||
**Existing buildings to extend with `enables_units`** (the culture path):
|
||||
- `gathering_hall.json` (in `public/resources/buildings/science_culture.json`) → add `tunnel_runner`
|
||||
- `chronicle_hall.json` (in `public/resources/buildings/`) → add `rune_scribe`
|
||||
- `chronicle_tower.json` (in `public/resources/buildings/`) → add `beacon_bearer`
|
||||
**Existing buildings to extend with `enables_units`** (the culture path — revised 2026-04-27 after wonder audit):
|
||||
- `gathering_hall.json` (in `public/resources/buildings/science_culture.json`) → adds `tunnel_runner` ✓ (tier 2, NOT a wonder — legitimate normal culture building)
|
||||
- ~~`chronicle_hall`~~ — phantom building, see era_4 row above
|
||||
- ~~`chronicle_tower`~~ — tier-8 World Wonder (verified: `wonder_type: "world"`, cost 620, +35% empire culture). Wiring couriers into a wonder mis-uses the wonder identity. Reverted in c4.
|
||||
|
||||
**Honest culture-path scope after wonder audit:** only era_3 has a non-wonder culture-tech building (`gathering_hall`) suitable for hosting a courier. Era 4–9 culture-tech unlocks are exclusively World Wonders (`grand_amphitheater`, `temple_of_the_ancestor`, `chronicle_tower`, `monument_of_ages`, `world_pillar`, `festival_grounds`, `triumph_arch`, `grand_orrery`, `testament_of_kings`, `the_undying_halls`). Couriers at those eras are tech-only.
|
||||
|
||||
From era_6 on, the intercept surface shifts from "kill the courier on the road" to "destroy the tower / pillage the wire / cut the resonance" — keeps the intercept-able-knowledge mechanic alive into late game. Beacon Tower (improvement) is killable; Steam Track and Resonance Wire (improvements) are pillage-able. From era_9 on the mesh of Hold-Network Citadels auto-reroutes around severed links.
|
||||
|
||||
|
|
@ -86,7 +88,7 @@ flavor stays Game 3 (Elves).
|
|||
## Acceptance criteria
|
||||
|
||||
- [ ] **Data pack — units (8 remaining + 1 done)**: 9 new unit JSONs in `public/games/age-of-dwarves/data/units/` matching the Dwarven ladder above: `foot_runner` ✓ (cycle 1), `tunnel_runner`, `rune_scribe`, `hold_courier`, `beacon_bearer`, `steam_messenger`, `resonance_telegrapher`, `hold_network_warden`. (No era_10 unit — Adamantine Echo is wonder-only.) Each declares its era, prerequisite tech, prerequisite building, movement speed, intercept rules, and upgrade-from chain.
|
||||
- [ ] **Data pack — buildings (revised 2026-04-27)**: 6 new building files (the linear hub chain + era_10 wonder): `messenger_hut` ✓, `hold_post`, `steam_forgery_annex`, `resonance_chamber`, `hold_network_citadel`, `adamantine_echo`. Plus 3 existing buildings extended with `enables_units` for the culture path: `gathering_hall`, `chronicle_hall`, `chronicle_tower`. Cycle 2 wrote 3 redundant building files that need to be DELETED (`tunnel_mouth`, `rune_scribe_hall`) or RELOCATED (`beacon_tower` → `improvements/`).
|
||||
- [ ] **Data pack — buildings (revised 2026-04-27, audited 2026-04-27)**: 6 new building files (the linear hub chain + era_10 wonder): `messenger_hut`, `hold_post`, `steam_forgery_annex`, `resonance_chamber`, `hold_network_citadel`, `adamantine_echo` — all authored cycle 1/2. Plus 1 existing non-wonder building extended with `enables_units` for the only legitimate culture path: `gathering_hall` (era_3 / Tunnel Runner). Era 4-9 culture paths abandoned after audit: every era_5+ culture-tech-unlocked building is `wonder_type: "world"`, and `chronicle_hall` (era_4) is a phantom (no file exists, only referenced in culture-tech unlocks).
|
||||
- [ ] **Data pack — improvements (revised 2026-04-27)**: 5 improvement files in `public/resources/improvements/` (canonical store) — `tunnel` (era_3), `hold_road` (era_5, upgrade of `road`), `steam_track` (era_7, severable), `resonance_wire` (era_8, severable), and **`beacon_tower`** (era_6, killable hilltop structure built by engineer, provides LOS chain — relocated from buildings/). Cycle 2 wrote the first 4; beacon_tower needs c3 to relocate + restructure to improvement schema.
|
||||
- [ ] **Data pack — techs**: 3 new prereq tech JSONs authored — `tunnel_paths` (era_3, ecology pillar), `beacon_chain` (era_6, military pillar), `rune_resonance` (era_8, metallurgy + runelore crossover). The other 6 tier prereqs (`tracking`, `runelore`, `dwarf_heritage`, `steam_forging`, `combined_arms`, `adamantine_forging`) all exist in the current tech tree — no work needed.
|
||||
- [ ] **Rust — `mc-trade` extension**: new `OpenBordersAgreement` and `SharedMapAgreement` types, with shared-map agreements requiring a `CourierRoute` resolved each turn (route exists / route severed / courier alive in transit).
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@
|
|||
"value": 0.1
|
||||
}
|
||||
],
|
||||
"enables_units": ["beacon_bearer"],
|
||||
"sprite": "sprites/buildings/wonders/chronicle_tower.png",
|
||||
"flavor": "The tower is never finished. That is the point.",
|
||||
"flags": [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue