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:
parent
3c053e5984
commit
c835096610
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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.
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue