|
@@ -1,10 +1,10 @@
|
|
|
import 'pixi.js/unsafe-eval'; // Pixi without eval, required by our Content-Security-Policy
|
|
import 'pixi.js/unsafe-eval'; // Pixi without eval, required by our Content-Security-Policy
|
|
|
-import { Application, BlurFilter, CanvasSource, Container, Sprite, Texture } from 'pixi.js';
|
|
|
|
|
|
|
+import { Application, CanvasSource, Container, Sprite, Texture } from 'pixi.js';
|
|
|
import { Anim, PLAYABLE_CHARACTER_IDS, parseCharacterStats } from '../shared/characters';
|
|
import { Anim, PLAYABLE_CHARACTER_IDS, parseCharacterStats } from '../shared/characters';
|
|
|
-import { DEFAULT_CHARACTER_ID, INTERPOLATION_DELAY_MS, PROTOCOL_VERSION } from '../shared/constants';
|
|
|
|
|
|
|
+import { DEFAULT_CHARACTER_ID, INTERPOLATION_DELAY_MS, PROTOCOL_VERSION, ROUND_DURATION_S } from '../shared/constants';
|
|
|
import { buildTerrain, parseMapDefinition } from '../shared/maps/loader';
|
|
import { buildTerrain, parseMapDefinition } from '../shared/maps/loader';
|
|
|
import { parseHexColor, parseMaterials, type MaterialRegistry } from '../shared/materials';
|
|
import { parseHexColor, parseMaterials, type MaterialRegistry } from '../shared/materials';
|
|
|
-import { hello, type ChunkInfo, type ChunkState, type FxEvent, type PlayerInfo, type PlayerState, type ServerMessage } from '../shared/protocol';
|
|
|
|
|
|
|
+import { hello, type ChunkInfo, type ChunkState, type FxEvent, type Phase, type PlayerInfo, type PlayerState, type ServerMessage } from '../shared/protocol';
|
|
|
import type { TerrainBitmap } from '../shared/terrain/bitmap';
|
|
import type { TerrainBitmap } from '../shared/terrain/bitmap';
|
|
|
import { applyTerrainEvent, type SequencedEvent, type TerrainEvent } from '../shared/terrain/events';
|
|
import { applyTerrainEvent, type SequencedEvent, type TerrainEvent } from '../shared/terrain/events';
|
|
|
import { rleDecode } from '../shared/terrain/rle';
|
|
import { rleDecode } from '../shared/terrain/rle';
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
DREAM_SUPERSAMPLE,
|
|
DREAM_SUPERSAMPLE,
|
|
|
BACKGROUND_BLUR,
|
|
BACKGROUND_BLUR,
|
|
|
MAX_RESOLUTION,
|
|
MAX_RESOLUTION,
|
|
|
- POISON_BLUR,
|
|
|
|
|
INPUT_SEND_HZ,
|
|
INPUT_SEND_HZ,
|
|
|
LANDING_DUST_MIN_VY,
|
|
LANDING_DUST_MIN_VY,
|
|
|
MERGE_SHAKE,
|
|
MERGE_SHAKE,
|
|
@@ -33,10 +32,11 @@ import {
|
|
|
SHAKE_PER_PIXEL,
|
|
SHAKE_PER_PIXEL,
|
|
|
WHISK_SHAKE,
|
|
WHISK_SHAKE,
|
|
|
} from './config';
|
|
} from './config';
|
|
|
|
|
+import { GameAudio, type SoundName } from './audio/Audio';
|
|
|
import { ChunkView } from './entities/ChunkView';
|
|
import { ChunkView } from './entities/ChunkView';
|
|
|
import { PlayerView } from './entities/PlayerView';
|
|
import { PlayerView } from './entities/PlayerView';
|
|
|
import { CherryView, CloudsView, CustardView, ItemsView, SpoonsView } from './entities/RoundViews';
|
|
import { CherryView, CloudsView, CustardView, ItemsView, SpoonsView } from './entities/RoundViews';
|
|
|
-import { makeDreamFilter, SugarDust } from './fx/Dream';
|
|
|
|
|
|
|
+import { makeDreamFilter, setDreamPoison, SugarDust } from './fx/Dream';
|
|
|
import { Particles } from './fx/Particles';
|
|
import { Particles } from './fx/Particles';
|
|
|
import { Popups } from './fx/Popups';
|
|
import { Popups } from './fx/Popups';
|
|
|
import { fr } from './i18n/fr';
|
|
import { fr } from './i18n/fr';
|
|
@@ -48,6 +48,8 @@ import { showBrowser } from './ui/browser';
|
|
|
import { ResultsScreen, RoundHud } from './ui/hud';
|
|
import { ResultsScreen, RoundHud } from './ui/hud';
|
|
|
import { LobbyPanel } from './ui/lobbyPanel';
|
|
import { LobbyPanel } from './ui/lobbyPanel';
|
|
|
import { showMenu, type MenuChoice } from './ui/menu';
|
|
import { showMenu, type MenuChoice } from './ui/menu';
|
|
|
|
|
+import { OptionsScreen } from './ui/options';
|
|
|
|
|
+import { settings } from './settings';
|
|
|
|
|
|
|
|
const PORTRAIT_RADIUS = 34;
|
|
const PORTRAIT_RADIUS = 34;
|
|
|
const EATING_POPUP_RANGE = 120;
|
|
const EATING_POPUP_RANGE = 120;
|
|
@@ -106,7 +108,7 @@ function makeVignette(): Sprite {
|
|
|
function makePortraits(app: Application): Map<string, HTMLCanvasElement> {
|
|
function makePortraits(app: Application): Map<string, HTMLCanvasElement> {
|
|
|
const out = new Map<string, HTMLCanvasElement>();
|
|
const out = new Map<string, HTMLCanvasElement>();
|
|
|
PLAYABLE_CHARACTER_IDS.forEach((characterId, i) => {
|
|
PLAYABLE_CHARACTER_IDS.forEach((characterId, i) => {
|
|
|
- const view = new PlayerView({ id: i + 1, name: '', characterId, radius: PORTRAIT_RADIUS });
|
|
|
|
|
|
|
+ const view = new PlayerView({ id: i + 1, name: '', characterId, radius: PORTRAIT_RADIUS, variant: 0 });
|
|
|
view.drawIdle();
|
|
view.drawIdle();
|
|
|
out.set(characterId, app.renderer.extract.canvas({ target: view.container, resolution: 3 }) as HTMLCanvasElement);
|
|
out.set(characterId, app.renderer.extract.canvas({ target: view.container, resolution: 3 }) as HTMLCanvasElement);
|
|
|
view.destroy();
|
|
view.destroy();
|
|
@@ -115,11 +117,11 @@ function makePortraits(app: Application): Map<string, HTMLCanvasElement> {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** Title menu then game browser, until the player picks a game to host or join. */
|
|
/** Title menu then game browser, until the player picks a game to host or join. */
|
|
|
-async function chooseGame(app: Application): Promise<{ choice: MenuChoice; address: string; port: number }> {
|
|
|
|
|
|
|
+async function chooseGame(app: Application, options: OptionsScreen): Promise<{ choice: MenuChoice; address: string; port: number }> {
|
|
|
const portraits = makePortraits(app);
|
|
const portraits = makePortraits(app);
|
|
|
const stats = new Map((await window.assets.characters()).map((raw) => parseCharacterStats(raw)).map((st) => [st.id, st]));
|
|
const stats = new Map((await window.assets.characters()).map((raw) => parseCharacterStats(raw)).map((st) => [st.id, st]));
|
|
|
for (;;) {
|
|
for (;;) {
|
|
|
- const choice = await showMenu(PLAYABLE_CHARACTER_IDS, DEFAULT_CHARACTER_ID, portraits, stats);
|
|
|
|
|
|
|
+ const choice = await showMenu(PLAYABLE_CHARACTER_IDS, DEFAULT_CHARACTER_ID, portraits, stats, options);
|
|
|
const target = await showBrowser();
|
|
const target = await showBrowser();
|
|
|
if (target.mode === 'back') continue;
|
|
if (target.mode === 'back') continue;
|
|
|
if (target.mode === 'join') return { choice, address: target.address, port: target.port };
|
|
if (target.mode === 'join') return { choice, address: target.address, port: target.port };
|
|
@@ -142,15 +144,19 @@ async function start(): Promise<void> {
|
|
|
});
|
|
});
|
|
|
host.appendChild(app.canvas);
|
|
host.appendChild(app.canvas);
|
|
|
|
|
|
|
|
- const { choice, address, port } = await chooseGame(app);
|
|
|
|
|
|
|
+ const audio = new GameAudio();
|
|
|
|
|
+ const options = new OptionsScreen();
|
|
|
|
|
+ const { choice, address, port } = await chooseGame(app, options);
|
|
|
helpEl.textContent = fr.help[choice.characterId] ?? '';
|
|
helpEl.textContent = fr.help[choice.characterId] ?? '';
|
|
|
setStatus(fr.connecting);
|
|
setStatus(fr.connecting);
|
|
|
|
|
|
|
|
const camera = new Container();
|
|
const camera = new Container();
|
|
|
const worldRoot = new Container();
|
|
const worldRoot = new Container();
|
|
|
camera.addChild(worldRoot);
|
|
camera.addChild(worldRoot);
|
|
|
- // Dreamy look over the whole world (the poison blur lives on worldRoot).
|
|
|
|
|
- camera.filters = [makeDreamFilter(app.renderer.resolution * DREAM_SUPERSAMPLE)];
|
|
|
|
|
|
|
+ // Dreamy look over the whole world; it also blurs the sight of a poisoned player.
|
|
|
|
|
+ const dream = makeDreamFilter(app.renderer.resolution * DREAM_SUPERSAMPLE);
|
|
|
|
|
+ camera.filters = [dream];
|
|
|
|
|
+ let poison = 0;
|
|
|
const dust = new SugarDust();
|
|
const dust = new SugarDust();
|
|
|
const vignette = makeVignette();
|
|
const vignette = makeVignette();
|
|
|
app.stage.addChild(camera, vignette);
|
|
app.stage.addChild(camera, vignette);
|
|
@@ -163,8 +169,6 @@ async function start(): Promise<void> {
|
|
|
const custardView = new CustardView();
|
|
const custardView = new CustardView();
|
|
|
const spoonsView = new SpoonsView();
|
|
const spoonsView = new SpoonsView();
|
|
|
const cloudsView = new CloudsView();
|
|
const cloudsView = new CloudsView();
|
|
|
- // Toxic cloud: the whole world goes blurry for the poisoned local player.
|
|
|
|
|
- const poisonBlur = new BlurFilter({ strength: 0, quality: 2, resolution: 0.5 });
|
|
|
|
|
// Map-independent layers always sit above the map ones (background, terrain).
|
|
// Map-independent layers always sit above the map ones (background, terrain).
|
|
|
// The custard covers players who sink into it; the spoon flicks out of it.
|
|
// The custard covers players who sink into it; the spoon flicks out of it.
|
|
|
worldRoot.addChild(
|
|
worldRoot.addChild(
|
|
@@ -274,6 +278,12 @@ async function start(): Promise<void> {
|
|
|
if (m) particles.burst(op.x, op.y, Math.ceil((count / 100) * PARTICLES_PER_100_PX), parseHexColor(m.color));
|
|
if (m) particles.burst(op.x, op.y, Math.ceil((count / 100) * PARTICLES_PER_100_PX), parseHexColor(m.color));
|
|
|
}
|
|
}
|
|
|
shake = Math.min(SHAKE_MAX, shake + total * SHAKE_PER_PIXEL);
|
|
shake = Math.min(SHAKE_MAX, shake + total * SHAKE_PER_PIXEL);
|
|
|
|
|
+ // The sound of the material that was eaten most (materials.json "sfx").
|
|
|
|
|
+ let loudest = 0;
|
|
|
|
|
+ let loudestCount = 0;
|
|
|
|
|
+ for (const [mat, count] of removedByMaterial) if (count > loudestCount) [loudest, loudestCount] = [mat, count];
|
|
|
|
|
+ const sfx = world.materials.byId[loudest]?.sfx;
|
|
|
|
|
+ if (sfx && loudestCount > 0) audio.play(sfx as SoundName, op.x, op.y, Math.min(1, 0.35 + loudestCount / 400));
|
|
|
if (total >= POPUP_MIN_PIXELS && popupCooldown <= 0) {
|
|
if (total >= POPUP_MIN_PIXELS && popupCooldown <= 0) {
|
|
|
popupCooldown = POPUP_COOLDOWN;
|
|
popupCooldown = POPUP_COOLDOWN;
|
|
|
popups.spawn(op.x, op.y - op.r, nearEater(op.x, op.y) ? fr.onomatopoeia.munch : fr.onomatopoeia.crunch);
|
|
popups.spawn(op.x, op.y - op.r, nearEater(op.x, op.y) ? fr.onomatopoeia.munch : fr.onomatopoeia.crunch);
|
|
@@ -284,14 +294,20 @@ async function start(): Promise<void> {
|
|
|
const cells = rleDecode(ev.kind === 'detach' ? ev.mask : ev.patch, ev.w * ev.h);
|
|
const cells = rleDecode(ev.kind === 'detach' ? ev.mask : ev.patch, ev.w * ev.h);
|
|
|
if (ev.kind === 'detach') {
|
|
if (ev.kind === 'detach') {
|
|
|
if (!live) return;
|
|
if (!live) return;
|
|
|
- if (ev.debris) particles.burst(ev.x + ev.w / 2, ev.y + ev.h / 2, Math.max(3, Math.ceil(cells.length / 20)), mainColor(cells), 200);
|
|
|
|
|
- else addChunk(ev.id, cells, ev.w, ev.h, { id: ev.id, x: ev.x, y: ev.y, angle: 0 });
|
|
|
|
|
|
|
+ if (ev.debris) {
|
|
|
|
|
+ particles.burst(ev.x + ev.w / 2, ev.y + ev.h / 2, Math.max(3, Math.ceil(cells.length / 20)), mainColor(cells), 200);
|
|
|
|
|
+ audio.play('puff', ev.x + ev.w / 2, ev.y + ev.h / 2, 0.5);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ addChunk(ev.id, cells, ev.w, ev.h, { id: ev.id, x: ev.x, y: ev.y, angle: 0 });
|
|
|
|
|
+ audio.play('crrrac', ev.x + ev.w / 2, ev.y + ev.h / 2);
|
|
|
|
|
+ }
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
// Merge: the sprite becomes terrain again.
|
|
// Merge: the sprite becomes terrain again.
|
|
|
removeChunk(ev.id);
|
|
removeChunk(ev.id);
|
|
|
if (!live) return;
|
|
if (!live) return;
|
|
|
particles.puff(ev.x + ev.w / 2, ev.y + ev.h, 12);
|
|
particles.puff(ev.x + ev.w / 2, ev.y + ev.h, 12);
|
|
|
|
|
+ audio.play('thud', ev.x + ev.w / 2, ev.y + ev.h);
|
|
|
shake = Math.min(SHAKE_MAX, shake + MERGE_SHAKE);
|
|
shake = Math.min(SHAKE_MAX, shake + MERGE_SHAKE);
|
|
|
popups.spawn(ev.x + ev.w / 2, ev.y, fr.onomatopoeia.thud, 30);
|
|
popups.spawn(ev.x + ev.w / 2, ev.y, fr.onomatopoeia.thud, 30);
|
|
|
};
|
|
};
|
|
@@ -333,7 +349,14 @@ async function start(): Promise<void> {
|
|
|
/** A character's signature move: everyone sees (and soon hears) it. */
|
|
/** A character's signature move: everyone sees (and soon hears) it. */
|
|
|
const onCue = (ev: Extract<FxEvent, { kind: 'cue' }>): void => {
|
|
const onCue = (ev: Extract<FxEvent, { kind: 'cue' }>): void => {
|
|
|
const size = states.get(ev.id)?.size ?? 24;
|
|
const size = states.get(ev.id)?.size ?? 24;
|
|
|
|
|
+ audio.play(ev.cue.startsWith('spell-') ? 'spell' : (ev.cue as SoundName), ev.x, ev.y);
|
|
|
switch (ev.cue) {
|
|
switch (ev.cue) {
|
|
|
|
|
+ case 'pounce':
|
|
|
|
|
+ popups.spawn(ev.x, ev.y - size - 16, fr.fxWords.pounce, 30);
|
|
|
|
|
+ break;
|
|
|
|
|
+ case 'backfire':
|
|
|
|
|
+ popups.spawn(ev.x, ev.y - size - 20, fr.fxWords.backfire, 34);
|
|
|
|
|
+ break;
|
|
|
case 'prout':
|
|
case 'prout':
|
|
|
particles.puff(ev.x, ev.y + size, 8, 0x9be15d);
|
|
particles.puff(ev.x, ev.y + size, 8, 0x9be15d);
|
|
|
popups.spawn(ev.x, ev.y + size + 10, fr.fxWords.prout, 34);
|
|
popups.spawn(ev.x, ev.y + size + 10, fr.fxWords.prout, 34);
|
|
@@ -361,13 +384,16 @@ async function start(): Promise<void> {
|
|
|
const onFx = (ev: FxEvent): void => {
|
|
const onFx = (ev: FxEvent): void => {
|
|
|
switch (ev.kind) {
|
|
switch (ev.kind) {
|
|
|
case 'splash':
|
|
case 'splash':
|
|
|
|
|
+ audio.play('splash', ev.x, ev.y, ev.id ? 1 : 0.5);
|
|
|
particles.burst(ev.x, ev.y, 16, CUSTARD_COLOR, 420);
|
|
particles.burst(ev.x, ev.y, 16, CUSTARD_COLOR, 420);
|
|
|
popups.spawn(ev.x, ev.y - 30, fr.fxWords.splash, ev.id ? 36 : 26);
|
|
popups.spawn(ev.x, ev.y - 30, fr.fxWords.splash, ev.id ? 36 : 26);
|
|
|
break;
|
|
break;
|
|
|
case 'spoon':
|
|
case 'spoon':
|
|
|
|
|
+ audio.play('spoon', ev.x, ev.y);
|
|
|
spoonsView.spawn(ev.x, ev.y);
|
|
spoonsView.spawn(ev.x, ev.y);
|
|
|
break;
|
|
break;
|
|
|
case 'explosion':
|
|
case 'explosion':
|
|
|
|
|
+ audio.play('explosion', ev.x, ev.y);
|
|
|
particles.burst(ev.x, ev.y, 40, 0xffb02e, 650);
|
|
particles.burst(ev.x, ev.y, 40, 0xffb02e, 650);
|
|
|
particles.burst(ev.x, ev.y, 20, 0xff6f91, 400);
|
|
particles.burst(ev.x, ev.y, 20, 0xff6f91, 400);
|
|
|
particles.puff(ev.x, ev.y, 14, 0xfff3a0);
|
|
particles.puff(ev.x, ev.y, 14, 0xfff3a0);
|
|
@@ -376,16 +402,19 @@ async function start(): Promise<void> {
|
|
|
freezeUntil = performance.now() + FREEZE_FRAME_MS;
|
|
freezeUntil = performance.now() + FREEZE_FRAME_MS;
|
|
|
break;
|
|
break;
|
|
|
case 'pickup':
|
|
case 'pickup':
|
|
|
|
|
+ audio.play(ev.item === 'cherry' ? 'cherry' : 'pickup', ev.x, ev.y);
|
|
|
popups.spawn(ev.x, ev.y - 20, [fr.items[ev.item] ?? ev.item], 28);
|
|
popups.spawn(ev.x, ev.y - 20, [fr.items[ev.item] ?? ev.item], 28);
|
|
|
particles.burst(ev.x, ev.y, 10, 0xffe14d, 250);
|
|
particles.burst(ev.x, ev.y, 10, 0xffe14d, 250);
|
|
|
break;
|
|
break;
|
|
|
case 'cherryDrop':
|
|
case 'cherryDrop':
|
|
|
|
|
+ audio.play('cherryDrop', ev.x, ev.y);
|
|
|
popups.spawn(ev.x, ev.y - 40, fr.fxWords.drop, 32);
|
|
popups.spawn(ev.x, ev.y - 40, fr.fxWords.drop, 32);
|
|
|
break;
|
|
break;
|
|
|
case 'cue':
|
|
case 'cue':
|
|
|
onCue(ev);
|
|
onCue(ev);
|
|
|
break;
|
|
break;
|
|
|
case 'whisk':
|
|
case 'whisk':
|
|
|
|
|
+ audio.play('whisk');
|
|
|
shake = Math.min(SHAKE_MAX, shake + WHISK_SHAKE);
|
|
shake = Math.min(SHAKE_MAX, shake + WHISK_SHAKE);
|
|
|
for (const s of states.values()) {
|
|
for (const s of states.values()) {
|
|
|
if (s.id !== ev.id) particles.burst(s.x, s.y, 8, 0x4dc4ff, 300);
|
|
if (s.id !== ev.id) particles.burst(s.x, s.y, 8, 0x4dc4ff, 300);
|
|
@@ -399,8 +428,23 @@ async function start(): Promise<void> {
|
|
|
() => localId,
|
|
() => localId,
|
|
|
(ready) => conn.send({ t: 'ready', ready }),
|
|
(ready) => conn.send({ t: 'ready', ready }),
|
|
|
(rotation) => conn.send({ t: 'rotation', rotation }),
|
|
(rotation) => conn.send({ t: 'rotation', rotation }),
|
|
|
|
|
+ PLAYABLE_CHARACTER_IDS,
|
|
|
|
|
+ (characterId) => conn.send({ t: 'character', characterId }),
|
|
|
);
|
|
);
|
|
|
let mapNames = new Map<string, string>();
|
|
let mapNames = new Map<string, string>();
|
|
|
|
|
+ let phase: Phase = 'lobby';
|
|
|
|
|
+ let hostId = 0;
|
|
|
|
|
+ let countdown: number | null = null;
|
|
|
|
|
+ options.setGameActions({
|
|
|
|
|
+ canStop: () => hostId === localId && phase !== 'lobby',
|
|
|
|
|
+ onStop: () => conn.send({ t: 'stopMatch' }),
|
|
|
|
|
+ // Back to the title screen: a fresh page (the hosted server keeps running for the others).
|
|
|
|
|
+ onQuit: () => window.location.reload(),
|
|
|
|
|
+ });
|
|
|
|
|
+ /** Help line for the character actually played (it may be drawn at random, or imposed). */
|
|
|
|
|
+ const updateHelp = (): void => {
|
|
|
|
|
+ helpEl.textContent = fr.help[infos.get(localId)?.characterId ?? choice.characterId] ?? '';
|
|
|
|
|
+ };
|
|
|
|
|
|
|
|
const onMessage = (msg: ServerMessage): void => {
|
|
const onMessage = (msg: ServerMessage): void => {
|
|
|
switch (msg.t) {
|
|
switch (msg.t) {
|
|
@@ -409,18 +453,19 @@ async function start(): Promise<void> {
|
|
|
round = msg.round;
|
|
round = msg.round;
|
|
|
rounds = msg.rounds;
|
|
rounds = msg.rounds;
|
|
|
infos = new Map(msg.players.map((p) => [p.id, p]));
|
|
infos = new Map(msg.players.map((p) => [p.id, p]));
|
|
|
|
|
+ updateHelp();
|
|
|
resetWorld(msg.mapId, msg.events, msg.chunks);
|
|
resetWorld(msg.mapId, msg.events, msg.chunks);
|
|
|
break;
|
|
break;
|
|
|
case 'start': {
|
|
case 'start': {
|
|
|
round = msg.round;
|
|
round = msg.round;
|
|
|
rounds = msg.rounds;
|
|
rounds = msg.rounds;
|
|
|
infos = new Map(msg.players.map((p) => [p.id, p]));
|
|
infos = new Map(msg.players.map((p) => [p.id, p]));
|
|
|
- // The character may differ from the one chosen in the menu (imposed for this round).
|
|
|
|
|
- const mine = infos.get(localId)?.characterId ?? choice.characterId;
|
|
|
|
|
- helpEl.textContent = fr.help[mine] ?? '';
|
|
|
|
|
|
|
+ // The character may differ from the one chosen in the menu (imposed, or drawn at random).
|
|
|
|
|
+ updateHelp();
|
|
|
results.hide();
|
|
results.hide();
|
|
|
resetWorld(msg.mapId, [], []);
|
|
resetWorld(msg.mapId, [], []);
|
|
|
if (round === 0) break; // back to the warm-up: no fanfare
|
|
if (round === 0) break; // back to the warm-up: no fanfare
|
|
|
|
|
+ audio.play('go');
|
|
|
setStatus(fr.hud.round(round, rounds));
|
|
setStatus(fr.hud.round(round, rounds));
|
|
|
setTimeout(() => {
|
|
setTimeout(() => {
|
|
|
if (statusEl.textContent === fr.hud.round(round, rounds)) setStatus('');
|
|
if (statusEl.textContent === fr.hud.round(round, rounds)) setStatus('');
|
|
@@ -428,15 +473,31 @@ async function start(): Promise<void> {
|
|
|
break;
|
|
break;
|
|
|
}
|
|
}
|
|
|
case 'roundEnd':
|
|
case 'roundEnd':
|
|
|
|
|
+ audio.play('roundEnd');
|
|
|
results.showRound(msg, localId);
|
|
results.showRound(msg, localId);
|
|
|
break;
|
|
break;
|
|
|
case 'matchEnd':
|
|
case 'matchEnd':
|
|
|
|
|
+ audio.play('fanfare');
|
|
|
results.showMatch(msg, localId);
|
|
results.showMatch(msg, localId);
|
|
|
break;
|
|
break;
|
|
|
|
|
+ case 'nextIn':
|
|
|
|
|
+ results.setNextIn(msg.seconds);
|
|
|
|
|
+ break;
|
|
|
|
|
+ case 'matchStopped':
|
|
|
|
|
+ results.hide();
|
|
|
|
|
+ setStatus(fr.stopped);
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ if (statusEl.textContent === fr.stopped) setStatus('');
|
|
|
|
|
+ }, 2500);
|
|
|
|
|
+ break;
|
|
|
case 'fx':
|
|
case 'fx':
|
|
|
onFx(msg.ev);
|
|
onFx(msg.ev);
|
|
|
break;
|
|
break;
|
|
|
case 'lobby':
|
|
case 'lobby':
|
|
|
|
|
+ if (msg.countdown !== null && msg.countdown !== countdown) audio.play('beep');
|
|
|
|
|
+ countdown = msg.countdown;
|
|
|
|
|
+ phase = msg.phase;
|
|
|
|
|
+ hostId = msg.hostId;
|
|
|
lobbyPanel.update(msg);
|
|
lobbyPanel.update(msg);
|
|
|
mapNames = new Map(msg.maps.map((m) => [m.id, m.name]));
|
|
mapNames = new Map(msg.maps.map((m) => [m.id, m.name]));
|
|
|
break;
|
|
break;
|
|
@@ -457,6 +518,7 @@ async function start(): Promise<void> {
|
|
|
break;
|
|
break;
|
|
|
case 'playerJoined':
|
|
case 'playerJoined':
|
|
|
infos.set(msg.player.id, msg.player);
|
|
infos.set(msg.player.id, msg.player);
|
|
|
|
|
+ if (msg.player.id === localId) updateHelp(); // changed character during the warm-up
|
|
|
break;
|
|
break;
|
|
|
case 'playerLeft':
|
|
case 'playerLeft':
|
|
|
infos.delete(msg.id);
|
|
infos.delete(msg.id);
|
|
@@ -474,6 +536,7 @@ async function start(): Promise<void> {
|
|
|
removeChunk(msg.id);
|
|
removeChunk(msg.id);
|
|
|
break;
|
|
break;
|
|
|
case 'crushed':
|
|
case 'crushed':
|
|
|
|
|
+ audio.play('crush', msg.x, msg.y);
|
|
|
popups.spawn(msg.x, msg.y, fr.onomatopoeia.crush, 46);
|
|
popups.spawn(msg.x, msg.y, fr.onomatopoeia.crush, 46);
|
|
|
particles.burst(msg.x, msg.y, 24, PLAYER_SPLAT_COLOR, 500);
|
|
particles.burst(msg.x, msg.y, 24, PLAYER_SPLAT_COLOR, 500);
|
|
|
shake = Math.min(SHAKE_MAX, shake + CRUSH_SHAKE);
|
|
shake = Math.min(SHAKE_MAX, shake + CRUSH_SHAKE);
|
|
@@ -502,7 +565,8 @@ async function start(): Promise<void> {
|
|
|
|
|
|
|
|
setInterval(() => {
|
|
setInterval(() => {
|
|
|
const [aimX, aimY] = screenToWorld(input.mouseX, input.mouseY);
|
|
const [aimX, aimY] = screenToWorld(input.mouseX, input.mouseY);
|
|
|
- conn.send({ t: 'input', buttons: input.buttons, aimX: Math.round(aimX), aimY: Math.round(aimY) });
|
|
|
|
|
|
|
+ input.paused = options.isOpen;
|
|
|
|
|
+ conn.send({ t: 'input', buttons: input.active, aimX: Math.round(aimX), aimY: Math.round(aimY) });
|
|
|
}, 1000 / INPUT_SEND_HZ);
|
|
}, 1000 / INPUT_SEND_HZ);
|
|
|
|
|
|
|
|
let camX = 0;
|
|
let camX = 0;
|
|
@@ -554,12 +618,17 @@ async function start(): Promise<void> {
|
|
|
playersLayer.addChild(view.container);
|
|
playersLayer.addChild(view.container);
|
|
|
}
|
|
}
|
|
|
const ev = view.update(state, id === localId ? aimX : state.x + state.facing * 100, id === localId ? aimY : state.y, dt);
|
|
const ev = view.update(state, id === localId ? aimX : state.x + state.facing * 100, id === localId ? aimY : state.y, dt);
|
|
|
- if (ev.landed && ev.landingSpeed > LANDING_DUST_MIN_VY) particles.puff(state.x, state.y + state.size, 8);
|
|
|
|
|
|
|
+ if (ev.landed && ev.landingSpeed > LANDING_DUST_MIN_VY) {
|
|
|
|
|
+ particles.puff(state.x, state.y + state.size, 8);
|
|
|
|
|
+ audio.play('land', state.x, state.y, Math.min(1, ev.landingSpeed / 1200));
|
|
|
|
|
+ }
|
|
|
|
|
+ if (ev.jumped) audio.play('jump', state.x, state.y, id === localId ? 0.8 : 0.4);
|
|
|
// Smarties: a sugary trail.
|
|
// Smarties: a sugary trail.
|
|
|
if (state.anim & Anim.Boosted && Math.random() < 0.5) {
|
|
if (state.anim & Anim.Boosted && Math.random() < 0.5) {
|
|
|
particles.burst(state.x, state.y + state.size * 0.5, 1, [0xff3b6b, 0xffe14d, 0x4dc4ff, 0x9be15d][Math.floor(Math.random() * 4)], 60);
|
|
particles.burst(state.x, state.y + state.size * 0.5, 1, [0xff3b6b, 0xffe14d, 0x4dc4ff, 0x9be15d][Math.floor(Math.random() * 4)], 60);
|
|
|
}
|
|
}
|
|
|
if (ev.stunnedNow) {
|
|
if (ev.stunnedNow) {
|
|
|
|
|
+ audio.play('hit', state.x, state.y);
|
|
|
popups.spawn(state.x, state.y - state.size, fr.onomatopoeia.hit, 40);
|
|
popups.spawn(state.x, state.y - state.size, fr.onomatopoeia.hit, 40);
|
|
|
particles.burst(state.x, state.y, 10, 0xffe14d, 300);
|
|
particles.burst(state.x, state.y, 10, 0xffe14d, 300);
|
|
|
shake = Math.min(SHAKE_MAX, shake + HIT_SHAKE);
|
|
shake = Math.min(SHAKE_MAX, shake + HIT_SHAKE);
|
|
@@ -572,8 +641,9 @@ async function start(): Promise<void> {
|
|
|
camera.scale.set(scale);
|
|
camera.scale.set(scale);
|
|
|
const me = states.get(localId);
|
|
const me = states.get(localId);
|
|
|
const poisoned = me !== undefined && (me.anim & Anim.Poisoned) !== 0;
|
|
const poisoned = me !== undefined && (me.anim & Anim.Poisoned) !== 0;
|
|
|
- poisonBlur.strength += ((poisoned ? POISON_BLUR : 0) - poisonBlur.strength) * Math.min(1, 6 * dt);
|
|
|
|
|
- worldRoot.filters = poisonBlur.strength > 0.2 ? [poisonBlur] : [];
|
|
|
|
|
|
|
+ // Toxic cloud: the world goes blurry for the poisoned local player (fades in and out).
|
|
|
|
|
+ poison += ((poisoned ? 1 : 0) - poison) * Math.min(1, 6 * dt);
|
|
|
|
|
+ setDreamPoison(dream, poison < 0.01 ? 0 : poison);
|
|
|
const viewW = app.screen.width / scale;
|
|
const viewW = app.screen.width / scale;
|
|
|
const viewH = app.screen.height / scale;
|
|
const viewH = app.screen.height / scale;
|
|
|
if (me) {
|
|
if (me) {
|
|
@@ -588,6 +658,11 @@ async function start(): Promise<void> {
|
|
|
const cx = viewW >= width ? (width - viewW) / 2 : Math.max(0, Math.min(width - viewW, camX));
|
|
const cx = viewW >= width ? (width - viewW) / 2 : Math.max(0, Math.min(width - viewW, camX));
|
|
|
const cy = viewH >= height ? (height - viewH) / 2 : Math.max(0, Math.min(height - viewH, camY));
|
|
const cy = viewH >= height ? (height - viewH) / 2 : Math.max(0, Math.min(height - viewH, camY));
|
|
|
shake = Math.max(0, shake - SHAKE_DECAY * dt);
|
|
shake = Math.max(0, shake - SHAKE_DECAY * dt);
|
|
|
|
|
+ if (!settings().shake) shake = 0;
|
|
|
|
|
+ audio.setListener(cx + viewW / 2, cy + viewH / 2);
|
|
|
|
|
+ // The tune speeds up as the round goes, and races during the final rush.
|
|
|
|
|
+ const timeLeft = sample.round?.timeLeft ?? null;
|
|
|
|
|
+ audio.setMusic(true, timeLeft === null ? 0 : sample.round?.finalRush ? 1 : 0.4 * (1 - timeLeft / ROUND_DURATION_S));
|
|
|
worldRoot.position.set(Math.round(-cx + (Math.random() - 0.5) * shake * 2), Math.round(-cy + (Math.random() - 0.5) * shake * 2));
|
|
worldRoot.position.set(Math.round(-cx + (Math.random() - 0.5) * shake * 2), Math.round(-cy + (Math.random() - 0.5) * shake * 2));
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|