diff --git a/assets/fx/ta-50cal.mp3 b/assets/fx/ta-50cal.mp3 new file mode 100644 index 0000000..5654db1 Binary files /dev/null and b/assets/fx/ta-50cal.mp3 differ diff --git a/assets/fx/ta-machinegun.mp3 b/assets/fx/ta-machinegun.mp3 new file mode 100644 index 0000000..40d35be Binary files /dev/null and b/assets/fx/ta-machinegun.mp3 differ diff --git a/assets/fx/ta-rocket-1.mp3 b/assets/fx/ta-rocket-1.mp3 new file mode 100644 index 0000000..74b796e Binary files /dev/null and b/assets/fx/ta-rocket-1.mp3 differ diff --git a/assets/fx/ta-rocket-2.mp3 b/assets/fx/ta-rocket-2.mp3 new file mode 100644 index 0000000..2644cfa Binary files /dev/null and b/assets/fx/ta-rocket-2.mp3 differ diff --git a/assets/fx/ta-unit-loss.mp3 b/assets/fx/ta-unit-loss.mp3 new file mode 100644 index 0000000..49df7fb Binary files /dev/null and b/assets/fx/ta-unit-loss.mp3 differ diff --git a/assets/fx/ta-vehicle-loss.mp3 b/assets/fx/ta-vehicle-loss.mp3 new file mode 100644 index 0000000..db65f8a Binary files /dev/null and b/assets/fx/ta-vehicle-loss.mp3 differ diff --git a/assets/images/ta/background.png b/assets/images/ta/background.png new file mode 100644 index 0000000..c2c2dcd Binary files /dev/null and b/assets/images/ta/background.png differ diff --git a/assets/images/ta/core-units.png b/assets/images/ta/core-units.png new file mode 100644 index 0000000..b874fb7 Binary files /dev/null and b/assets/images/ta/core-units.png differ diff --git a/assets/images/ta/core-units.psd b/assets/images/ta/core-units.psd new file mode 100644 index 0000000..990a9a7 Binary files /dev/null and b/assets/images/ta/core-units.psd differ diff --git a/data/totalannihilation-artwork.json b/data/totalannihilation-artwork.json index 4820f55..269c9c2 100644 --- a/data/totalannihilation-artwork.json +++ b/data/totalannihilation-artwork.json @@ -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", diff --git a/data/totalannihilation-rules.json b/data/totalannihilation-rules.json index e7535c3..0111b8b 100644 --- a/data/totalannihilation-rules.json +++ b/data/totalannihilation-rules.json @@ -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": { diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 4e49c47..6071e3d 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -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: [ diff --git a/src/games/totalannihilation/TALogic.js b/src/games/totalannihilation/TALogic.js index 4b56e72..9547b95 100644 --- a/src/games/totalannihilation/TALogic.js +++ b/src/games/totalannihilation/TALogic.js @@ -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) { diff --git a/src/games/totalannihilation/TAScreens.js b/src/games/totalannihilation/TAScreens.js index a5731a5..e1b63c8 100644 --- a/src/games/totalannihilation/TAScreens.js +++ b/src/games/totalannihilation/TAScreens.js @@ -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; diff --git a/src/games/totalannihilation/TAWorldView.js b/src/games/totalannihilation/TAWorldView.js index a166335..24cba9e 100644 --- a/src/games/totalannihilation/TAWorldView.js +++ b/src/games/totalannihilation/TAWorldView.js @@ -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); } } diff --git a/src/games/totalannihilation/TotalAnnihilationGame.js b/src/games/totalannihilation/TotalAnnihilationGame.js index 7349035..a862527 100644 --- a/src/games/totalannihilation/TotalAnnihilationGame.js +++ b/src/games/totalannihilation/TotalAnnihilationGame.js @@ -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) {