feat(total-annihilation): polish visuals, audio, and game balance

- Add TA-specific sound effects (machinegun, 50cal, rocket variants, unit/vehicle loss)
- Add background artwork and update core-units sprite sheet with correct path and dimensions
- Introduce pintle gun weapon with burst fire mechanics
- Switch weapon sounds to TA-themed audio; support sound arrays for random variant picking
- Add impactSound field to projectile weapons, propagated through sim events
- Reduce unit build times and increase structure HP for better pacing
- Enable idle breathing/rotation animations on resource generators
- Replace solid-color backdrop with painted background image on main menu
- Fix camera state cleanup so menus render correctly after gameplay
This commit is contained in:
Brian Fertig 2026-07-25 19:51:25 -06:00
parent 3c053e5984
commit c835096610
16 changed files with 132 additions and 28 deletions

BIN
assets/fx/ta-50cal.mp3 Normal file

Binary file not shown.

BIN
assets/fx/ta-machinegun.mp3 Normal file

Binary file not shown.

BIN
assets/fx/ta-rocket-1.mp3 Normal file

Binary file not shown.

BIN
assets/fx/ta-rocket-2.mp3 Normal file

Binary file not shown.

BIN
assets/fx/ta-unit-loss.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

View File

@ -39,8 +39,8 @@
"frameWidth": 192, "frameHeight": 192, "cols": 6
},
"core-units": {
"key": "ta-core-units", "path": null, "kind": "unit",
"frameWidth": 64, "frameHeight": 64, "cols": 8
"key": "ta-core-units", "path": "assets/images/ta/core-units.png", "kind": "unit",
"frameWidth": 128, "frameHeight": 128, "cols": 8
},
"core-structures": {
"key": "ta-core-structures", "path": null, "kind": "structure",

View File

@ -245,7 +245,35 @@
"width": 1.5,
"lifeMs": 70
},
"sound": "sfx-battle-gunfire-modern"
"sound": "sfx-ta-machinegun"
},
{
"id": "pintlegun",
"name": "Pintle Gun",
"kind": "hitscan",
"damage": 9,
"reload": 0.5,
"burst": 3,
"burstDelay": 0.06,
"range": 200,
"spread": 0.05,
"targets": [
"ground"
],
"armorMul": {
"infantry": 1.3,
"light": 0.85,
"medium": 0.3,
"heavy": 0.12,
"structure": 0.2
},
"fx": {
"style": "tracer",
"color": "#ffe6a0",
"width": 1.5,
"lifeMs": 70
},
"sound": "sfx-ta-50cal"
},
{
"id": "sniperrifle",
@ -335,7 +363,8 @@
"width": 2,
"trail": true
},
"sound": "sfx-battle-missle"
"sound": "sfx-ta-rocket-1",
"impactSound": "sfx-ta-rocket-2"
},
{
"id": "commanderlaser",
@ -448,7 +477,7 @@
"width": 2.5,
"trail": true
},
"sound": "sfx-battle-missle"
"sound": ["sfx-ta-rocket-1", "sfx-ta-rocket-2"]
}
],
"units": [
@ -523,7 +552,7 @@
"energy": 120,
"mass": 24
},
"buildTime": 6,
"buildTime": 2,
"builtBy": [
"barracks"
],
@ -553,7 +582,7 @@
"energy": 260,
"mass": 55
},
"buildTime": 12,
"buildTime": 6,
"builtBy": [
"barracks"
],
@ -588,7 +617,7 @@
"vehicleplant"
],
"weapons": [
"rifle"
"pintlegun"
],
"sheetSlot": "unitSheet",
"frame": 4,
@ -669,7 +698,7 @@
"w": 2,
"h": 2
},
"hp": 1440,
"hp": 2440,
"armorClass": "structure",
"sight": 160,
"cost": {
@ -691,7 +720,8 @@
"frame": 0,
"buildFrame": 1,
"procShape": "energyGen",
"icon": 10
"icon": 10,
"idleAnimation": true
},
{
"id": "massgen",
@ -700,7 +730,7 @@
"w": 2,
"h": 2
},
"hp": 1260,
"hp": 2260,
"armorClass": "structure",
"sight": 160,
"cost": {
@ -722,7 +752,8 @@
"frame": 2,
"buildFrame": 3,
"procShape": "massGen",
"icon": 11
"icon": 11,
"idleAnimation": true
},
{
"id": "barracks",
@ -731,7 +762,7 @@
"w": 3,
"h": 3
},
"hp": 2520,
"hp": 3520,
"armorClass": "structure",
"sight": 220,
"cost": {
@ -761,7 +792,7 @@
"w": 3,
"h": 3
},
"hp": 3960,
"hp": 4960,
"armorClass": "structure",
"sight": 220,
"cost": {
@ -792,7 +823,7 @@
"w": 1,
"h": 1
},
"hp": 1300,
"hp": 3300,
"armorClass": "structure",
"sight": 300,
"cost": {
@ -823,7 +854,7 @@
"w": 2,
"h": 2
},
"hp": 1700,
"hp": 4700,
"armorClass": "structure",
"sight": 420,
"cost": {

View File

@ -244,10 +244,15 @@ export const MANIFEST = {
{ type: 'json', key: 'totalannihilation-rules', path: 'data/totalannihilation-rules.json' },
{ type: 'json', key: 'totalannihilation-campaign', path: 'data/totalannihilation-campaign.json' },
(scene) => sheetsFrom(scene, 'totalannihilation-artwork', ['sheets']),
image('ta-background', 'assets/images/ta/background.png'),
// Weapon sounds keyed by each weapon's `sound` in totalannihilation-rules.json.
...['gunfire-modern', 'tank', 'missle'].map((n) => ({ type: 'audio', key: `sfx-battle-${n}`, path: `assets/fx/battle-${n}.mp3` })),
...['tank'].map((n) => ({ type: 'audio', key: `sfx-battle-${n}`, path: `assets/fx/battle-${n}.mp3` })),
{ type: 'audio', key: 'sfx-laser-zap', path: 'assets/fx/laser-zap.mp3' },
{ type: 'audio', key: 'sfx-scifi-launch', path: 'assets/fx/scifi-launch.mp3' },
// TA-specific sfx: machine guns per weapon.sound, rocket variants (random pick between the
// two on fire, see TotalAnnihilationGame._onSimEvent), and unit/vehicle death cues.
...['50cal', 'machinegun', 'rocket-1', 'rocket-2', 'unit-loss', 'vehicle-loss']
.map((n) => ({ type: 'audio', key: `sfx-ta-${n}`, path: `assets/fx/ta-${n}.mp3` })),
(scene) => musicFrom(scene, 'hacker-music'),
],
coloradodefense: [

View File

@ -1232,7 +1232,7 @@ function fireWeapon(state, rules, e, w, target, facing, aimPt) {
if (hit) {
applyDamage(state, rules, target, w.damage * (w.armorMul?.[rules.defById[target.defId].armorClass] ?? 1), e);
if (w.aoe) applyAoe(state, rules, aim.x, aim.y, w, e);
state.events.push({ t: 'impact', x: ex, y: ey, radius: w.aoe ?? 8, style: w.fx.style });
state.events.push({ t: 'impact', x: ex, y: ey, radius: w.aoe ?? 8, style: w.fx.style, sound: w.impactSound });
}
return;
}
@ -1320,7 +1320,7 @@ function stepProjectiles(state, rules) {
}
if (detonate) {
state.events.push({ t: 'impact', x: p.x, y: p.y, radius: w.aoe ?? 12, style: w.fx.style });
state.events.push({ t: 'impact', x: p.x, y: p.y, radius: w.aoe ?? 12, style: w.fx.style, sound: w.impactSound });
if (w.aoe) applyAoe(state, rules, p.x, p.y, w, { army: p.army, id: p.ownerId });
else {
for (const e of state.entities) {

View File

@ -25,12 +25,26 @@ function screen(scene, objs) {
}
function backdrop(scene, objs, title, subtitle) {
objs.push(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, BG, 1).setOrigin(0, 0).setDepth(900)
.setInteractive()); // swallow clicks so they never reach the world beneath
const t = scene.add.text(GAME_WIDTH / 2, 92, title, {
fontFamily: FONT, fontSize: '76px', color: TEXT,
}).setOrigin(0.5).setDepth(901);
objs.push(t);
const bg = scene.textures.exists('ta-background')
? scene.add.image(0, 0, 'ta-background').setOrigin(0, 0).setDisplaySize(GAME_WIDTH, GAME_HEIGHT)
: scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, BG, 1).setOrigin(0, 0);
objs.push(bg.setDepth(900).setInteractive()); // swallow clicks so they never reach the world beneath
// The art is a full painted scene, not a UI colour field — every screen dims it a notch so
// loose text (no background box of its own, unlike the buttons) stays legible over it.
objs.push(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, BG, 0.55).setOrigin(0, 0).setDepth(900));
let t = null;
if (title) {
// The art's own "Total Annihilation" logo sits right in this band — a plain flat scrim
// isn't enough to keep a screen's own title readable over it, so screens that draw one
// get an extra band of darkening here. The main menu (no title passed) skips this and
// shows the logo at full strength instead.
objs.push(scene.add.rectangle(0, 0, GAME_WIDTH, 230, BG, 0.6).setOrigin(0, 0).setDepth(900));
t = scene.add.text(GAME_WIDTH / 2, 92, title, {
fontFamily: FONT, fontSize: '76px', color: TEXT,
}).setOrigin(0.5).setDepth(901);
objs.push(t);
}
if (subtitle) {
objs.push(scene.add.text(GAME_WIDTH / 2, 158, subtitle, {
fontFamily: FONT, fontSize: '24px', color: DIM,
@ -89,10 +103,13 @@ function cycler(scene, objs, x, y, label, options, index, onChange) {
export function mainMenu(scene, { hasSave, onSkirmish, onCampaign, onContinue, onLeave }) {
const objs = [];
backdrop(scene, objs, 'TOTAL ANNIHILATION', 'Commander — your war machine awaits');
// The background art carries its own "Total Annihilation" title card — drawing the usual
// backdrop() title/subtitle on top of it would just double up the logo.
backdrop(scene, objs, null, null);
const cx = GAME_WIDTH / 2;
let y = 330;
// Below the art's own logo, which runs down to roughly y=430.
let y = 560;
button(scene, objs, cx, y, 460, 76, 'SKIRMISH', onSkirmish); y += 100;
button(scene, objs, cx, y, 460, 76, 'CAMPAIGN', onCampaign); y += 100;
button(scene, objs, cx, y, 460, 76, 'CONTINUE', onContinue, { enabled: hasSave }); y += 100;

View File

@ -417,6 +417,30 @@ export default class TAWorldView {
}
}
// Idle animation for finished resource buildings (energy/mass generators): a slow
// breathing pulse plus a continuous clockwise rotation, so a base full of them reads as
// active rather than static scenery. Each pulse cycle and each quarter-turn re-rolls its
// own random duration, so buildings never fall into visible lockstep with each other.
if (def.isBuilding && def.idleAnimation && !e.site) {
const now = this.scene.time.now;
if (!s.idlePulse || now - s.idlePulse.start >= s.idlePulse.ms) {
s.idlePulse = { start: now, ms: (4 + Math.random() * 4) * 1000 };
}
const pulseT = (now - s.idlePulse.start) / s.idlePulse.ms;
const scale = 0.95 - 0.05 * Math.cos(pulseT * Math.PI * 2);
s.img.setDisplaySize(def.footprint.w * this.ts * scale, def.footprint.h * this.ts * scale);
if (!s.idleRot) s.idleRot = { start: now, ms: 8000 + Math.random() * 4000, turns: 0 };
while (now - s.idleRot.start >= s.idleRot.ms) {
s.idleRot.start += s.idleRot.ms;
s.idleRot.turns++;
s.idleRot.ms = 8000 + Math.random() * 4000;
}
const turnT = (now - s.idleRot.start) / s.idleRot.ms;
s.img.setRotation((s.idleRot.turns + turnT) * (Math.PI / 2));
}
if (s.turret) {
const tr = lerpAngle(e.pturretRot, e.turretRot, alpha);
s.turret.setPosition(x, y).setRotation(tr);
@ -623,6 +647,19 @@ export default class TAWorldView {
this.worldRoot.destroy(true);
this.uiRoot.destroy(true);
if (this.uiCam) this.scene.cameras.remove(this.uiCam);
// _setupCameras() bounded, zoomed and re-tinted the scene's MAIN camera for the match —
// that camera is shared with (not owned by) this view and outlives it, so whatever menu
// screen comes next inherits it as-is. Without this, the menu's full-screen rectangles
// (built assuming a camera at scroll (0,0), zoom 1) end up positioned outside whatever
// stale scroll/bounds gameplay left behind — the menu is technically there and clickable,
// just scrolled off into empty space, which reads as a black, unresponsive screen.
const cam = this.scene.cameras.main;
cam.removeBounds?.();
cam.setZoom(1);
cam.setScroll(0, 0);
const bg = this.scene.game?.config?.backgroundColor;
if (bg) cam.setBackgroundColor(bg);
}
}

View File

@ -256,7 +256,16 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
_onSimEvent(ev) {
this.fx.onEvent(ev, this.rules);
if (ev.t === 'weaponFired' && ev.sound) this._throttledSfx(ev.sound, 90);
// A weapon's fire/impact sound may be a single key or a list of variants (e.g. a weapon
// that alternates between two clips) — resolve to one key before it reaches the throttle
// gate, so each variant is throttled independently rather than the array being treated as
// one key.
if (ev.t === 'weaponFired' && ev.sound) this._throttledSfx(this._pickSound(ev.sound), 90);
if (ev.t === 'impact' && ev.sound) this._throttledSfx(this._pickSound(ev.sound), 90);
if (ev.t === 'unitDestroyed' && !ev.isBuilding) {
const moveClass = this.rules.defById[ev.defId]?.moveClass;
if (moveClass) this._throttledSfx(moveClass === 'foot' ? 'sfx-ta-unit-loss' : 'sfx-ta-vehicle-loss', 120);
}
if (ev.t === 'buildingComplete' && ev.army === this.playerArmy) {
// Terrain under a finished structure changes, so its chunk has to be restamped.
this.view.repaintArea(ev.x - 128, ev.y - 128, ev.x + 128, ev.y + 128);
@ -275,6 +284,11 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
}
}
/** A sound field is either one key or a list of variants to pick between at random. */
_pickSound(sound) {
return Array.isArray(sound) ? sound[Math.floor(Math.random() * sound.length)] : sound;
}
/** Gate repeated plays of the same sound key so a volley of units firing at once doesn't
* stack a dozen overlapping instances of the same clip. */
_throttledSfx(key, gapMs) {