Improve netcode and simplify (#4)
All checks were successful
Build & deploy / Build & deploy website (push) Successful in 32s
Build & deploy / Build & publish server image (push) Successful in 1m2s

- Simplify the logic wherever possible to remove overengineering and simplify the netcode
- Actually improve the netcode
- The new lines are mainly the package locks and tests

Reviewed-on: https://home.schmelczer.dev/git/andras/decla-red/pulls/4
This commit is contained in:
Andras Schmelczer 2026-09-15 07:57:57 +01:00
commit 93a7fe01ab
50 changed files with 15073 additions and 4754 deletions

View file

@ -23,7 +23,7 @@
"resize-observer-polyfill": "^1.5.1",
"sass": "^1.100.0",
"sass-loader": "^17.0.0",
"sdf-2d": "file:../../sdf-2d",
"sdf-2d": "^0.8.0",
"shared": "file:../shared",
"socket.io-client": "^4.8.3",
"socket.io-msgpack-parser": "^3.0.2",
@ -36,40 +36,6 @@
"webpack-dev-server": "^5.2.4"
}
},
"../../sdf-2d": {
"version": "0.7.6",
"dev": true,
"license": "ISC",
"dependencies": {
"gl-matrix": "^3.4.4",
"resize-observer-polyfill": "^1.5.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^17.6.0",
"prettier": "^3.8.3",
"raw-loader": "^4.0.2",
"terser-webpack-plugin": "^5.6.1",
"ts-loader": "^9.6.0",
"typedoc": "^0.28.19",
"typedoc-plugin-extras": "^4.0.1",
"typescript": "^6.0.3",
"webpack": "^5.107.2",
"webpack-cli": "^7.0.3"
}
},
"../../sdf-2d/dist": {
"extraneous": true
},
"../../sdf-2d/lib": {
"extraneous": true
},
"../shared": {
"version": "0.0.0",
"dev": true,
@ -5443,8 +5409,22 @@
}
},
"node_modules/sdf-2d": {
"resolved": "../../sdf-2d",
"link": true
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/sdf-2d/-/sdf-2d-0.8.0.tgz",
"integrity": "sha512-rqM3RMm71ytlLmlzC9lwsUFOv9uvodgtgRRZrv69IV5nh9OlGKk25RJRe2sRLK3u3LDvt0h+4KwthQu7GVRyJg==",
"dev": true,
"license": "ISC",
"dependencies": {
"gl-matrix": "^3.4.4",
"resize-observer-polyfill": "^1.5.1"
}
},
"node_modules/sdf-2d/node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"dev": true,
"license": "MIT"
},
"node_modules/select-hose": {
"version": "2.0.0",

View file

@ -3,13 +3,11 @@ declare module '*.mp3' {
export default content;
}
// Asset imports handled by webpack loaders. TypeScript 5.x+ requires ambient
// declarations for side-effect imports of these, so declare them here.
// TypeScript 5.x+ requires ambient declarations for side-effect asset imports.
declare module '*.png';
declare module '*.ico';
declare module '*.svg';
declare module '*.scss';
// webpack's `mode` inlines `process.env.NODE_ENV` via DefinePlugin. Declare just
// that one global so browser code can read it without pulling in all of @types/node.
// Declare just NODE_ENV (DefinePlugin inlines it) so browser code need not pull in all of @types/node.
declare const process: { env: { NODE_ENV?: string } };

View file

@ -13,20 +13,19 @@ import '../static/favicons/apple-touch-icon.png';
import '../static/favicons/favicon-16x16.png';
import '../static/favicons/favicon-32x32.png';
import '../static/favicons/favicon.ico';
import ResizeObserver from 'resize-observer-polyfill';
import { LandingPageBackground } from './scripts/landing-page-background';
import { JoinFormHandler } from './scripts/join-form-handler';
import { JoinFormHandler, PlayerDecision } from './scripts/join-form-handler';
import { handleFullScreen } from './scripts/helper/handle-full-screen';
import { Game } from './scripts/game';
import ResizeObserver from 'resize-observer-polyfill';
import { OptionsHandler } from './scripts/options-handler';
import { hide } from './scripts/helper/hide';
import { show } from './scripts/helper/show';
import { initializeOptions } from './scripts/options-handler';
import { SoundHandler, Sounds } from './scripts/sound-handler';
import { VibrationHandler } from './scripts/vibration-handler';
import { CharacterView } from './scripts/objects/types/character-view';
import { LampView } from './scripts/objects/types/lamp-view';
import { PlanetView } from './scripts/objects/types/planet-view';
import { ProjectileView } from './scripts/objects/types/projectile-view';
import { loadStoredValue, saveStoredValue } from './scripts/helper/storage';
glMatrix.setMatrixArrayType(Array);
@ -35,23 +34,36 @@ overrideDeserialization(PlanetBase, PlanetView);
overrideDeserialization(LampBase, LampView);
overrideDeserialization(ProjectileBase, ProjectileView);
const landingUI = document.querySelector('#landing-ui') as HTMLElement;
const nameInput = document.querySelector('#name') as HTMLInputElement;
const joinGameForm = document.querySelector('#join-game-form') as HTMLFormElement;
const serverContainer = document.querySelector('#server-container') as HTMLElement;
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
const overlay = document.querySelector('#overlay') as HTMLElement;
const settings = document.querySelector('#settings') as HTMLElement;
const toggleSettingsButton = document.querySelector(
'#toggle-settings-container',
) as HTMLElement;
const minimize = document.querySelector('#minimize') as HTMLElement;
const maximize = document.querySelector('#maximize') as HTMLElement;
const logoutButton = document.querySelector('#logout') as HTMLElement;
const enableSounds = document.querySelector('#enable-sounds') as HTMLInputElement;
const enableMusic = document.querySelector('#enable-music') as HTMLInputElement;
const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement;
const spinner = document.querySelector('#spinner-container') as HTMLElement;
const query = <T extends HTMLElement>(selector: string) =>
document.querySelector(selector) as T;
const landingUI = query('#landing-ui');
const nameInput = query<HTMLInputElement>('#name');
const joinGameForm = query<HTMLFormElement>('#join-game-form');
const serverContainer = query('#server-container');
const canvas = query<HTMLCanvasElement>('canvas');
const overlay = query('#overlay');
const settings = query('#settings');
const toggleSettingsButton = query('#toggle-settings-container');
const minimize = query('#minimize');
const maximize = query('#maximize');
const logoutButton = query('#logout');
const enableSounds = query<HTMLInputElement>('#enable-sounds');
const enableMusic = query<HTMLInputElement>('#enable-music');
const enableVibration = query<HTMLInputElement>('#enable-vibration');
const spinner = query('#spinner-container');
const joinNotice = document.createElement('p');
joinNotice.className = 'join-notice';
joinNotice.style.display = 'none';
joinGameForm.prepend(joinNotice);
const setVisible = (e: HTMLElement, visible: boolean) => {
e.style.visibility = visible ? 'inherit' : 'hidden';
};
const setDisplayed = (e: HTMLElement, display: string | null) => {
e.style.display = display ?? 'none';
};
const toggleSettings = () => {
settings.className = settings.className === 'open' ? '' : 'open';
@ -63,98 +75,105 @@ const applyServerContainerShadows = () => {
const bottomShadow = 'inset 0 8px 8px -8px rgba(0, 0, 0, 0.4)';
const { scrollHeight, clientHeight, scrollTop } = serverContainer;
if (scrollHeight > clientHeight) {
if (scrollTop <= 0) {
serverContainer.style.boxShadow = topShadow;
} else if (scrollTop + clientHeight >= scrollHeight) {
serverContainer.style.boxShadow = bottomShadow;
} else {
serverContainer.style.boxShadow = topShadow + ',' + bottomShadow;
}
} else {
if (scrollHeight <= clientHeight) {
serverContainer.style.boxShadow = '';
} else if (scrollTop <= 0) {
serverContainer.style.boxShadow = topShadow;
} else if (scrollTop + clientHeight >= scrollHeight) {
serverContainer.style.boxShadow = bottomShadow;
} else {
serverContainer.style.boxShadow = topShadow + ',' + bottomShadow;
}
};
const main = async () => {
try {
let game: Game;
let game: Game | undefined;
const storedUserName = localStorage?.getItem('userName');
if (storedUserName) {
nameInput.value = JSON.parse(storedUserName);
}
const storedUserName = loadStoredValue('userName');
if (typeof storedUserName === 'string') {
nameInput.value = storedUserName;
}
const firstClickListener = () => {
SoundHandler.initialize(
() => {
enableMusic.checked = true;
enableMusic.dispatchEvent(new Event('change'));
},
() => {
enableMusic.checked = false;
enableMusic.dispatchEvent(new Event('change'));
},
);
document.removeEventListener('click', firstClickListener);
};
document.addEventListener('click', firstClickListener);
const firstClickListener = () => {
SoundHandler.initialize(
() => {
enableMusic.checked = true;
enableMusic.dispatchEvent(new Event('change'));
},
() => {
enableMusic.checked = false;
enableMusic.dispatchEvent(new Event('change'));
},
);
document.removeEventListener('click', firstClickListener);
};
document.addEventListener('click', firstClickListener);
if (!VibrationHandler.isVibrationEnabledHeuristics) {
hide(document.querySelector("label[for='enable-vibration']") as HTMLElement, true);
}
if (!VibrationHandler.isVibrationEnabledHeuristics) {
setDisplayed(query("label[for='enable-vibration']"), null);
}
handleFullScreen(minimize, maximize);
toggleSettingsButton.addEventListener('click', toggleSettings);
handleFullScreen(minimize, maximize);
toggleSettingsButton.addEventListener('click', toggleSettings);
new ResizeObserver(applyServerContainerShadows).observe(serverContainer);
serverContainer.addEventListener('scroll', applyServerContainerShadows);
new ResizeObserver(applyServerContainerShadows).observe(serverContainer);
serverContainer.addEventListener('scroll', applyServerContainerShadows);
OptionsHandler.initialize({
soundsEnabled: enableSounds,
vibrationEnabled: enableVibration,
musicEnabled: enableMusic,
});
initializeOptions({
soundsEnabled: enableSounds,
vibrationEnabled: enableVibration,
musicEnabled: enableMusic,
});
logoutButton.addEventListener('click', () => {
game.destroy();
toggleSettings();
});
window.onpopstate = () => game.destroy();
logoutButton.addEventListener('click', () => {
game?.destroy();
toggleSettings();
});
window.onpopstate = () => game?.destroy();
for (;;) {
show(spinner);
hide(logoutButton, true);
show(landingUI, true, 'flex');
for (;;) {
setVisible(spinner, true);
setDisplayed(logoutButton, null);
setDisplayed(landingUI, 'flex');
const background = new LandingPageBackground(canvas);
const joinHandler = new JoinFormHandler(joinGameForm, serverContainer);
const background = new LandingPageBackground(canvas);
const joinHandler = new JoinFormHandler(joinGameForm, serverContainer);
let playerDecision: PlayerDecision;
try {
await background.renderer;
hide(spinner);
const playerDecision = await joinHandler.getPlayerDecision();
localStorage?.setItem('userName', JSON.stringify(playerDecision.name));
if (!history.state) {
history.pushState(true, '');
}
hide(landingUI, true);
show(spinner);
setVisible(spinner, false);
playerDecision = await joinHandler.getPlayerDecision();
} finally {
background.destroy();
game = new Game(playerDecision, canvas, overlay);
const gameOver = game.start();
await game.started;
hide(spinner);
show(logoutButton, true, 'block');
await gameOver;
joinHandler.destroy();
}
} catch (e) {
console.error(e);
alert(e);
saveStoredValue('userName', playerDecision.name);
if (!history.state) {
history.pushState(true, '');
}
setDisplayed(landingUI, null);
setVisible(spinner, true);
game = new Game(playerDecision, canvas, overlay);
const gameOver = game.start();
await game.started;
setVisible(spinner, false);
setDisplayed(logoutButton, 'block');
await gameOver;
const reason = game.rejectionReason;
joinNotice.innerText = reason ? Game.rejectionText(reason) : '';
setDisplayed(joinNotice, reason ? 'block' : null);
}
};
main();
main().catch((error) => {
console.error(error);
setVisible(spinner, false);
setDisplayed(landingUI, 'flex');
joinNotice.innerText = 'Something went wrong. Please reload the page.';
setDisplayed(joinNotice, 'block');
});

View file

@ -127,7 +127,6 @@ body {
.player-tag {
border-radius: 1000px;
transition: transform 200ms;
&.blue {
color: $bright-blue;
@ -231,8 +230,6 @@ body {
}
}
// Top-down radar of the whole arena (see Minimap). The circular border is
// the world boundary; dots are painted onto the canvas.
.minimap {
top: $small-padding;
left: $small-padding;
@ -247,16 +244,13 @@ body {
border-radius: 1000px;
background-color: rgba(0, 0, 0, 0.35);
box-shadow:
inset 0 0 3px 0 rgba(0, 0, 0, 0.4),
0 0 10px rgba(0, 0, 0, 0.3);
box-shadow: inset 0 0 3px 0 rgba(0, 0, 0, 0.4),
0 0 10px rgba(0, 0, 0, 0.3);
border: $border-width solid rgba(255, 255, 255, 0.25);
z-index: 100;
}
// Off-screen pointer to the keystone "Heart", tinted by its current owner.
.keystone-arrow {
transition: transform 150ms;
opacity: 0.9;
@include square($large-icon);
@ -333,8 +327,6 @@ body {
content: '\25C9';
}
// Twin-stick aim indicator: drawn from the button centre toward the
// current drag direction while aiming a shot (see TouchListener).
.aim-line {
position: absolute;
left: 50%;
@ -738,8 +730,6 @@ body {
}
}
// Centred "Eliminated" overlay shown while the local player is dead; the
// respawn countdown is the server-driven "Reviving in N…" announcement.
.elimination {
position: absolute;
top: 42%;
@ -937,4 +927,29 @@ body {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
}
.connection-banner {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
padding: 6px 14px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.62);
color: #fff;
font-size: 0.9rem;
letter-spacing: 0.02em;
pointer-events: none;
z-index: 20;
}
.join-notice {
margin: 0 0 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: 4px;
background: rgba(209, 86, 82, 0.18);
color: #f2c9c7;
font-size: 0.85rem;
text-align: center;
}

View file

@ -1,16 +1,9 @@
import { init as plausibleInit } from '@plausible-analytics/tracker';
const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true;
const ANALYTICS_DOMAIN = 'doppler.schmelczer.dev';
const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status';
const ANALYTICS_LOGGING = process.env.NODE_ENV !== 'production';
import { init } from '@plausible-analytics/tracker';
try {
plausibleInit({
domain: ANALYTICS_DOMAIN,
endpoint: ANALYTICS_ENDPOINT,
autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
logging: ANALYTICS_LOGGING,
init({
domain: 'doppler.schmelczer.dev',
endpoint: 'https://stats.schmelczer.dev/status',
});
} catch (error) {
console.warn('Could not initialize analytics.', error);

View file

@ -1,58 +1,58 @@
import { holdDurationToCharge } from 'shared';
import { Pointer } from './helper/pointer';
import { chargeHeldSince } from 'shared';
import { getDisplayPosition } from './helper/pointer';
export abstract class ChargeIndicator {
private static element?: HTMLElement;
private static heldSince = 0;
private static raf = 0;
private static followPointer = false;
let element: HTMLElement | undefined;
let heldSince = 0;
let raf = 0;
let isFollowingPointer = false;
public static begin(x: number, y: number, followPointer = false) {
this.end();
function beginChargeIndicator(x: number, y: number, followPointer = false) {
endChargeIndicator();
const element = document.createElement('div');
element.className = 'charge-ring';
element.style.left = `${x}px`;
element.style.top = `${y}px`;
document.body.appendChild(element);
const el = document.createElement('div');
el.className = 'charge-ring';
el.style.left = `${x}px`;
el.style.top = `${y}px`;
document.body.appendChild(el);
this.element = element;
this.heldSince = performance.now();
this.followPointer = followPointer;
this.raf = requestAnimationFrame(this.update);
}
public static end() {
if (this.element) {
cancelAnimationFrame(this.raf);
this.element.parentElement?.removeChild(this.element);
this.element = undefined;
}
}
private static update = () => {
const element = ChargeIndicator.element;
if (!element) {
return;
}
const charge = holdDurationToCharge(
(performance.now() - ChargeIndicator.heldSince) / 1000,
);
element.style.opacity = charge < 0.12 ? '0' : '1';
element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${
charge * 360
}deg, rgba(255, 255, 255, 0.15) 0deg)`;
element.classList.toggle('full', charge >= 1);
if (ChargeIndicator.followPointer) {
const cursor = Pointer.getDisplayPosition();
if (cursor) {
element.style.left = `${cursor.x}px`;
element.style.top = `${cursor.y}px`;
}
}
ChargeIndicator.raf = requestAnimationFrame(ChargeIndicator.update);
};
element = el;
heldSince = performance.now();
isFollowingPointer = followPointer;
raf = requestAnimationFrame(updateChargeIndicator);
}
function endChargeIndicator() {
if (element) {
cancelAnimationFrame(raf);
element.remove();
element = undefined;
}
}
function updateChargeIndicator() {
if (!element) {
return;
}
const charge = chargeHeldSince(heldSince);
element.style.opacity = charge < 0.12 ? '0' : '1';
element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${
charge * 360
}deg, rgba(255, 255, 255, 0.15) 0deg)`;
element.classList.toggle('full', charge >= 1);
if (isFollowingPointer) {
const cursor = getDisplayPosition();
if (cursor) {
element.style.left = `${cursor[0]}px`;
element.style.top = `${cursor[1]}px`;
}
}
raf = requestAnimationFrame(updateChargeIndicator);
}
export const ChargeIndicator = {
begin: beginChargeIndicator,
end: endChargeIndicator,
};

View file

@ -1,20 +1,50 @@
import { Command, CommandReceiver, serialize, TransportEvents } from 'shared';
import {
ClientHeartbeatCommand,
Command,
serialize,
settings,
TransportEvents,
} from 'shared';
import { Socket } from 'socket.io-client';
import { clientTimeMs } from '../helper/prediction/local-character-predictor';
export class CommandSocket extends CommandReceiver {
constructor(private readonly socket: Socket) {
super();
}
const heartbeatIntervalMs = settings.clientSendInterval * 1000;
// At an exact frame-rate/interval ratio (60 Hz vs 30 Hz) float noise would otherwise drop every third beat.
const dueToleranceMs = 1;
export class CommandSocket {
private commandQueue: Array<Command> = [];
protected defaultCommandExecutor(command: Command) {
private lastSendMs = -Infinity;
constructor(private readonly socket: Socket) {}
public queue(command: Command) {
this.commandQueue.push(command);
}
public reset() {
this.commandQueue = [];
this.lastSendMs = -Infinity;
}
public sendQueuedCommands() {
if (this.commandQueue.length > 0) {
this.socket.emit(TransportEvents.PlayerToServer, serialize(this.commandQueue));
// socket.io-client would buffer emits made while disconnected and flush them before the re-join.
if (!this.socket.connected) {
this.commandQueue = [];
return;
}
const nowMs = clientTimeMs();
if (
this.commandQueue.length === 0 &&
nowMs - this.lastSendMs < heartbeatIntervalMs - dueToleranceMs
) {
return;
}
this.lastSendMs = nowMs;
this.commandQueue.push(new ClientHeartbeatCommand(nowMs));
this.socket.emit(TransportEvents.PlayerToServer, serialize(this.commandQueue));
this.commandQueue = [];
}
}

View file

@ -0,0 +1,29 @@
import { vec2 } from 'gl-matrix';
import {
Command,
LeapActionCommand,
MoveActionCommand,
PrimaryActionCommand,
} from 'shared';
import {
clientTimeMs,
localCharacterPredictor,
} from '../helper/prediction/local-character-predictor';
export class InputGenerator {
constructor(protected readonly onCommand: (command: Command) => void) {}
protected sendMove(direction: vec2) {
this.onCommand(
new MoveActionCommand(direction, localCharacterPredictor.recordInput(direction)),
);
}
protected sendLeap() {
this.onCommand(new LeapActionCommand(localCharacterPredictor.recordLeap()));
}
protected sendPrimary(position: vec2, charge: number) {
this.onCommand(new PrimaryActionCommand(position, charge, clientTimeMs()));
}
}

View file

@ -1,53 +1,71 @@
import { vec2 } from 'gl-matrix';
import { CommandGenerator, LeapActionCommand, MoveActionCommand } from 'shared';
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
import type { Command } from 'shared';
import { InputGenerator } from './input-generator';
export class KeyboardListener extends CommandGenerator {
const movementKeys = new Set([
'KeyW',
'KeyA',
'KeyS',
'KeyD',
'ArrowUp',
'ArrowLeft',
'ArrowDown',
'ArrowRight',
]);
const leapKeys = new Set(['Space', 'ShiftLeft', 'ShiftRight']);
export class KeyboardListener extends InputGenerator {
private keysDown: Set<string> = new Set();
constructor() {
super();
constructor(onCommand: (command: Command) => void) {
super(onCommand);
addEventListener('keydown', this.keyDownListener);
addEventListener('keyup', this.keyUpListener);
addEventListener('blur', this.blurListener);
}
private keyDownListener = (event: KeyboardEvent) => {
const key = event.key.toLowerCase();
// Space leaps (W / ArrowUp already cover walking up). Edge-triggered so a
// held key's auto-repeat doesn't spam leaps.
if ((key === ' ' || key === 'shift') && !this.keysDown.has(key)) {
const clientTimeMs = localCharacterPredictor.recordLeap();
this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs));
const key = event.code;
if (!movementKeys.has(key) && !leapKeys.has(key)) {
return;
}
event.preventDefault();
if (this.keysDown.has(key)) {
return;
}
this.keysDown.add(key);
this.generateCommands();
if (leapKeys.has(key)) {
this.sendLeap();
} else {
this.sendMovement();
}
};
private keyUpListener = (event: KeyboardEvent) => {
this.keysDown.delete(event.key.toLowerCase());
this.generateCommands();
if (this.keysDown.delete(event.code) && movementKeys.has(event.code)) {
this.sendMovement();
}
};
private blurListener = () => {
this.keysDown.clear();
this.generateCommands();
this.sendMovement();
};
private generateCommands() {
const up = ~~(this.keysDown.has('w') || this.keysDown.has('arrowup'));
const down = ~~(this.keysDown.has('s') || this.keysDown.has('arrowdown'));
const left = ~~(this.keysDown.has('a') || this.keysDown.has('arrowleft'));
const right = ~~(this.keysDown.has('d') || this.keysDown.has('arrowright'));
const movement = vec2.fromValues(right - left, up - down);
if (vec2.squaredLength(movement) > 0) {
vec2.normalize(movement, movement);
public resendMovement() {
if ([...this.keysDown].some((key) => movementKeys.has(key))) {
this.sendMovement();
}
}
const clientTimeMs = localCharacterPredictor.recordInput(movement);
this.sendCommandToSubscribers(new MoveActionCommand(movement, clientTimeMs));
private sendMovement() {
const down = (...codes: Array<string>) =>
codes.some((c) => this.keysDown.has(c)) ? 1 : 0;
const movement = vec2.fromValues(
down('KeyD', 'ArrowRight') - down('KeyA', 'ArrowLeft'),
down('KeyW', 'ArrowUp') - down('KeyS', 'ArrowDown'),
);
this.sendMove(vec2.normalize(movement, movement));
}
public destroy() {

View file

@ -1,38 +1,35 @@
import { vec2 } from 'gl-matrix';
import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared';
import { Game } from '../game';
import { chargeHeldSince } from 'shared';
import type { Command } from 'shared';
import type { Game } from '../game';
import { ChargeIndicator } from '../charge-indicator';
import { Pointer } from '../helper/pointer';
import { setDisplayPosition } from '../helper/pointer';
import { InputGenerator } from './input-generator';
export class MouseListener extends CommandGenerator {
// Timestamp (ms) of the primary press, or null when not held. On release the
// held duration is mapped to the charge scalar; a quick tap reads as ~0.
export class MouseListener extends InputGenerator {
private primaryDownAt: number | null = null;
constructor(
private target: HTMLElement,
private readonly target: HTMLElement,
private readonly game: Game,
onCommand: (command: Command) => void,
) {
super();
super(onCommand);
target.addEventListener('mousedown', this.mouseDownListener);
target.addEventListener('mouseup', this.mouseUpListener);
target.addEventListener('mousemove', this.mouseMoveListener);
target.addEventListener('contextmenu', this.contextMenuListener);
window.addEventListener('mouseup', this.mouseUpListener);
window.addEventListener('blur', this.cancelPrimary);
}
// Only the screen position is stored; it is reprojected to world space each
// frame so the gaze stays correct even while the camera pans under a still
// cursor.
private mouseMoveListener = (event: MouseEvent) => {
Pointer.setDisplayPosition(event.clientX, event.clientY);
setDisplayPosition(vec2.fromValues(event.clientX, event.clientY));
};
private mouseDownListener = (event: MouseEvent) => {
if (event.button === 0) {
this.mouseMoveListener(event);
this.primaryDownAt = performance.now();
// The ring follows the cursor and only fades in once this press has
// clearly become a hold.
ChargeIndicator.begin(event.clientX, event.clientY, true);
}
};
@ -41,31 +38,30 @@ export class MouseListener extends CommandGenerator {
if (event.button !== 0 || this.primaryDownAt === null) {
return;
}
ChargeIndicator.end();
const charge = holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
const charge = chargeHeldSince(this.primaryDownAt);
this.primaryDownAt = null;
this.sendCommandToSubscribers(
new PrimaryActionCommand(this.positionFromEvent(event), charge),
this.sendPrimary(
this.game.displayToWorldCoordinates(vec2.fromValues(event.clientX, event.clientY)),
charge,
);
};
// Suppress the browser context menu on the canvas; right-click has no action.
private cancelPrimary = () => {
this.primaryDownAt = null;
ChargeIndicator.end();
};
private contextMenuListener = (event: MouseEvent) => {
event.preventDefault();
};
private positionFromEvent(event: MouseEvent): vec2 {
return this.game.displayToWorldCoordinates(
vec2.fromValues(event.clientX, event.clientY),
);
}
public destroy() {
ChargeIndicator.end();
this.target.removeEventListener('mousedown', this.mouseDownListener);
this.target.removeEventListener('mouseup', this.mouseUpListener);
this.target.removeEventListener('mousemove', this.mouseMoveListener);
this.target.removeEventListener('contextmenu', this.contextMenuListener);
window.removeEventListener('mouseup', this.mouseUpListener);
window.removeEventListener('blur', this.cancelPrimary);
}
}

View file

@ -1,254 +1,292 @@
import { vec2 } from 'gl-matrix';
import {
CommandGenerator,
MoveActionCommand,
last,
PrimaryActionCommand,
LeapActionCommand,
holdDurationToCharge,
settings,
} from 'shared';
import { Game } from '../game';
import { chargeHeldSince, settings } from 'shared';
import type { Command } from 'shared';
import type { Game } from '../game';
import { ChargeIndicator } from '../charge-indicator';
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
import { centeredTransform } from '../helper/centered-transform';
import { InputGenerator } from './input-generator';
export class TouchListener extends CommandGenerator {
private static readonly deadZone = 8;
private static readonly deltaScaling = 0.4;
// Min screen drag (px) from the fire button before a shot is aimed by the
// drag direction instead of firing straight ahead.
private static readonly aimDeadZone = 18;
const deadZone = 8;
const deltaScaling = 0.4;
const aimDeadZone = 18;
const joystickMaxLength = 20;
export class TouchListener extends InputGenerator {
private readonly joystick = document.createElement('div');
private readonly joystickButton = document.createElement('div');
private readonly fireButton = document.createElement('div');
private readonly fireStrengthRing = document.createElement('div');
private readonly fireAimLine = document.createElement('div');
private readonly leapButton = document.createElement('div');
private joystick: HTMLElement;
private joystickButton: HTMLElement;
private isJoystickActive = false;
private touchStartPosition!: vec2;
private touchStartPosition = vec2.create();
private movement = vec2.create();
private primaryDownAt: number | null = null;
private fireButton: HTMLElement;
private fireStrengthRing: HTMLElement;
private fireAimLine!: HTMLElement;
private leapButton: HTMLElement;
private gestureTouchId: number | null = null;
private fireDownAt: number | null = null;
private fireButtonCenter: vec2 | null = null;
private fireAimScreen: vec2 | null = null;
private fireTouchId: number | null = null;
private fireButtonCenter = vec2.create();
constructor(
private target: HTMLElement,
private overlay: HTMLElement,
private readonly target: HTMLElement,
private readonly overlay: HTMLElement,
private readonly game: Game,
onCommand: (command: Command) => void,
) {
super();
super(onCommand);
this.joystick = document.createElement('div');
this.joystick.className = 'joystick';
this.joystickButton = document.createElement('div');
this.joystick.appendChild(this.joystickButton);
this.fireButton = document.createElement('div');
this.fireButton.className = 'touch-button fire';
this.fireStrengthRing = document.createElement('div');
this.fireStrengthRing.className = 'strength-ring';
this.fireButton.appendChild(this.fireStrengthRing);
this.fireAimLine = document.createElement('div');
this.fireAimLine.className = 'aim-line';
this.fireButton.appendChild(this.fireAimLine);
this.fireButton.append(this.fireStrengthRing, this.fireAimLine);
this.fireButton.addEventListener('touchstart', this.fireButtonDownListener);
this.fireButton.addEventListener('touchmove', this.fireButtonMoveListener);
this.fireButton.addEventListener('touchend', this.fireButtonUpListener);
this.fireButton.addEventListener('touchcancel', this.fireButtonCancelListener);
this.leapButton = document.createElement('div');
this.leapButton.className = 'touch-button leap';
this.leapButton.addEventListener('touchstart', this.leapButtonListener);
this.overlay.appendChild(this.fireButton);
this.overlay.appendChild(this.leapButton);
target.addEventListener('touchstart', this.touchStartListener);
target.addEventListener('touchmove', this.touchMoveListener);
target.addEventListener('touchend', this.touchEndListener);
target.addEventListener('touchcancel', this.touchCancelListener);
window.addEventListener('blur', this.cancelTouches);
}
private findTouch(list: TouchList, id: number | null): Touch | undefined {
for (let i = 0; i < list.length; i++) {
if (list[i].identifier === id) {
return list[i];
}
}
return undefined;
}
private touchStartListener = (event: TouchEvent) => {
event.preventDefault();
const touch = event.changedTouches[event.changedTouches.length - 1];
if (!touch) {
return;
}
if (this.isJoystickActive) {
const center = vec2.fromValues(
last(event.touches)!.clientX,
last(event.touches)!.clientY,
);
this.sendCommandToSubscribers(
new PrimaryActionCommand(this.game.displayToWorldCoordinates(center)),
);
} else {
this.touchStartPosition = vec2.fromValues(
event.touches[0].clientX,
event.touches[0].clientY,
this.sendPrimary(
this.game.displayToWorldCoordinates(
vec2.fromValues(touch.clientX, touch.clientY),
),
0,
);
} else if (this.gestureTouchId === null) {
this.gestureTouchId = touch.identifier;
vec2.set(this.touchStartPosition, touch.clientX, touch.clientY);
this.primaryDownAt = performance.now();
ChargeIndicator.begin(this.touchStartPosition.x, this.touchStartPosition.y);
ChargeIndicator.begin(touch.clientX, touch.clientY);
}
};
private touchMoveListener = (event: TouchEvent) => {
event.preventDefault();
const touchPosition = vec2.fromValues(
event.touches[0].clientX,
event.touches[0].clientY,
);
const touch = this.findTouch(event.touches, this.gestureTouchId);
if (!touch) {
return;
}
const delta = vec2.subtract(vec2.create(), touchPosition, this.touchStartPosition);
vec2.scale(delta, delta, TouchListener.deltaScaling);
const delta = vec2.fromValues(
touch.clientX - this.touchStartPosition[0],
touch.clientY - this.touchStartPosition[1],
);
vec2.scale(delta, delta, deltaScaling);
const deltaLength = vec2.length(delta);
if (!this.isJoystickActive && deltaLength > TouchListener.deadZone) {
if (!this.isJoystickActive && deltaLength > deadZone) {
this.isJoystickActive = true;
this.primaryDownAt = null;
ChargeIndicator.end();
this.overlay.appendChild(this.joystick);
this.joystickButton.style.transform = `translateX(-50%) translateY(-50%)`;
this.joystick.style.transform = `translateX(${this.touchStartPosition.x}px) translateY(${this.touchStartPosition.y}px) translateX(-50%) translateY(-50%)`;
this.joystick.style.transform = centeredTransform(
this.touchStartPosition[0],
this.touchStartPosition[1],
);
}
const maxLength = 20;
vec2.scale(delta, delta, Math.min(1, maxLength / deltaLength));
this.joystickButton.style.transform = `translateX(${delta.x}px) translateY(${delta.y}px) translateX(-50%) translateY(-50%)`;
vec2.scale(delta, delta, Math.min(1, joystickMaxLength / deltaLength));
this.joystickButton.style.transform = centeredTransform(delta[0], delta[1]);
vec2.set(delta, delta.x, -delta.y);
if (deltaLength > TouchListener.deadZone) {
const direction = vec2.normalize(delta, delta);
this.sendMove(direction);
if (deltaLength > deadZone) {
vec2.set(delta, delta[0], -delta[1]);
vec2.normalize(this.movement, delta);
} else {
this.sendMove(vec2.create());
vec2.zero(this.movement);
}
this.sendMove(vec2.clone(this.movement));
};
private sendMove(direction: vec2) {
const clientTimeMs = localCharacterPredictor.recordInput(direction);
this.sendCommandToSubscribers(new MoveActionCommand(direction, clientTimeMs));
}
private touchEndListener = (event: TouchEvent) => {
event.preventDefault();
if (!this.isJoystickActive) {
ChargeIndicator.end();
const charge =
this.primaryDownAt === null
? 0
: holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
this.primaryDownAt = null;
const center = vec2.fromValues(
event.changedTouches[0].clientX,
event.changedTouches[0].clientY,
);
this.sendCommandToSubscribers(
new PrimaryActionCommand(this.game.displayToWorldCoordinates(center), charge),
);
} else if (event.touches.length === 0) {
this.isJoystickActive = false;
this.joystick.parentElement?.removeChild(this.joystick);
this.sendMove(vec2.create());
const touch = this.findTouch(event.changedTouches, this.gestureTouchId);
if (!touch) {
return;
}
this.gestureTouchId = null;
if (this.isJoystickActive) {
this.releaseJoystick();
return;
}
ChargeIndicator.end();
const charge = this.primaryDownAt === null ? 0 : chargeHeldSince(this.primaryDownAt);
this.primaryDownAt = null;
this.sendPrimary(
this.game.displayToWorldCoordinates(vec2.fromValues(touch.clientX, touch.clientY)),
charge,
);
};
private touchCancelListener = (event: TouchEvent) => {
if (!this.findTouch(event.changedTouches, this.gestureTouchId)) {
return;
}
this.gestureTouchId = null;
this.primaryDownAt = null;
ChargeIndicator.end();
if (this.isJoystickActive) {
this.releaseJoystick();
}
};
private swallowTouch = (event: TouchEvent) => {
private releaseJoystick() {
this.isJoystickActive = false;
this.joystick.remove();
vec2.zero(this.movement);
this.sendMove(vec2.clone(this.movement));
}
public resendMovement() {
if (this.isJoystickActive) {
this.sendMove(vec2.clone(this.movement));
}
}
private cancelTouches = () => {
this.gestureTouchId = null;
this.primaryDownAt = null;
this.cancelFire();
if (this.isJoystickActive) {
this.releaseJoystick();
}
};
private cancelFire() {
this.fireTouchId = null;
this.fireDownAt = null;
this.fireAimLine.style.opacity = '0';
ChargeIndicator.end();
}
private swallowTouch(event: TouchEvent) {
event.preventDefault();
event.stopPropagation();
};
}
private leapButtonListener = (event: TouchEvent) => {
this.swallowTouch(event);
const clientTimeMs = localCharacterPredictor.recordLeap();
this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs));
this.sendLeap();
};
private fireButtonDownListener = (event: TouchEvent) => {
this.swallowTouch(event);
const touch = event.changedTouches[0];
if (this.fireTouchId !== null || !touch) {
return;
}
this.fireTouchId = touch.identifier;
this.fireDownAt = performance.now();
const rect = this.fireButton.getBoundingClientRect();
this.fireButtonCenter = vec2.fromValues(
vec2.set(
this.fireButtonCenter,
rect.left + rect.width / 2,
rect.top + rect.height / 2,
);
this.fireAimScreen = null;
ChargeIndicator.begin(this.fireButtonCenter[0], this.fireButtonCenter[1]);
};
// Dragging from the fire button aims the shot: the drag vector sets the
// direction, decoupling aim from movement so a touch player can fire one way
// while walking another. A tap with no meaningful drag fires straight ahead.
private fireButtonMoveListener = (event: TouchEvent) => {
this.swallowTouch(event);
if (this.fireDownAt === null || !this.fireButtonCenter) {
const touch = this.findTouch(event.touches, this.fireTouchId);
if (this.fireDownAt === null || !touch) {
return;
}
const touch = event.targetTouches[0] ?? event.changedTouches[0];
if (!touch) {
return;
}
this.fireAimScreen = vec2.fromValues(touch.clientX, touch.clientY);
const dx = this.fireAimScreen[0] - this.fireButtonCenter[0];
const dy = this.fireAimScreen[1] - this.fireButtonCenter[1];
if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) {
const aim = this.aimFromTouch(touch);
if (aim) {
this.fireAimLine.style.opacity = '1';
this.fireAimLine.style.transform = `translateY(-50%) rotate(${Math.atan2(dy, dx)}rad)`;
this.fireAimLine.style.transform = `translateY(-50%) rotate(${Math.atan2(-aim[1], aim[0])}rad)`;
} else {
this.fireAimLine.style.opacity = '0';
}
};
private aimFromTouch(touch: Touch): vec2 | null {
const dx = touch.clientX - this.fireButtonCenter[0];
const dy = touch.clientY - this.fireButtonCenter[1];
if (dx * dx + dy * dy <= aimDeadZone * aimDeadZone) {
return null;
}
const aim = vec2.fromValues(dx, -dy);
return vec2.normalize(aim, aim);
}
private fireButtonUpListener = (event: TouchEvent) => {
this.swallowTouch(event);
ChargeIndicator.end();
this.fireAimLine.style.opacity = '0';
if (this.fireDownAt === null) {
const touch = this.findTouch(event.changedTouches, this.fireTouchId);
if (this.fireDownAt === null || !touch) {
return;
}
const charge = holdDurationToCharge((performance.now() - this.fireDownAt) / 1000);
this.fireDownAt = null;
const charge = chargeHeldSince(this.fireDownAt);
const aim = this.aimFromTouch(touch);
this.cancelFire();
const character = this.game.gameObjects.player;
const character = this.game.gameObjects.localPlayer;
if (!character) {
this.fireButtonCenter = null;
this.fireAimScreen = null;
return;
}
// Screen drag → world aim direction (flip Y: screen +y is down). Below the
// dead-zone it's a tap, so fall back to firing along the facing direction.
let direction = character.facingDirection;
if (this.fireButtonCenter && this.fireAimScreen) {
const dx = this.fireAimScreen[0] - this.fireButtonCenter[0];
const dy = this.fireAimScreen[1] - this.fireButtonCenter[1];
if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) {
direction = vec2.normalize(vec2.create(), vec2.fromValues(dx, -dy));
}
}
this.fireButtonCenter = null;
this.fireAimScreen = null;
const aim = vec2.scaleAndAdd(
vec2.create(),
character.bodyCenter,
direction,
settings.touchAimRange,
const direction = aim ?? character.facingDirection;
this.sendPrimary(
vec2.scaleAndAdd(
vec2.create(),
character.bodyCenter,
direction,
settings.touchAimRange,
),
charge,
);
this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge));
};
public update(_deltaTimeInSeconds: number) {
if (!this.fireButton.parentElement) {
this.overlay.appendChild(this.fireButton);
private fireButtonCancelListener = (event: TouchEvent) => {
this.swallowTouch(event);
if (this.findTouch(event.changedTouches, this.fireTouchId)) {
this.cancelFire();
}
if (!this.leapButton.parentElement) {
this.overlay.appendChild(this.leapButton);
};
public update() {
if (!this.fireButton.parentElement) {
this.overlay.append(this.fireButton, this.leapButton);
}
if (this.isJoystickActive && !this.joystick.parentElement) {
this.overlay.appendChild(this.joystick);
}
const character = this.game.gameObjects.player;
const character = this.game.gameObjects.localPlayer;
if (character) {
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${
character.strengthFraction * 360
@ -261,13 +299,17 @@ export class TouchListener extends CommandGenerator {
this.target.removeEventListener('touchstart', this.touchStartListener);
this.target.removeEventListener('touchmove', this.touchMoveListener);
this.target.removeEventListener('touchend', this.touchEndListener);
this.target.removeEventListener('touchcancel', this.touchCancelListener);
window.removeEventListener('blur', this.cancelTouches);
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener);
this.fireButton.removeEventListener('touchend', this.fireButtonUpListener);
this.fireButton.removeEventListener('touchcancel', this.fireButtonCancelListener);
this.leapButton.removeEventListener('touchstart', this.leapButtonListener);
this.fireButton.parentElement?.removeChild(this.fireButton);
this.leapButton.parentElement?.removeChild(this.leapButton);
this.fireButton.remove();
this.leapButton.remove();
this.joystick.remove();
}
}

View file

@ -1,7 +0,0 @@
import { Command } from 'shared';
export class BeforeDestroyCommand extends Command {
public constructor() {
super();
}
}

View file

@ -1,12 +0,0 @@
import { Renderer } from 'sdf-2d';
import { Command } from 'shared';
export class RenderCommand extends Command {
public constructor(
public readonly renderer: Renderer,
public readonly overlay: HTMLElement,
public readonly shouldChangeLayout: boolean,
) {
super();
}
}

View file

@ -1,7 +0,0 @@
import { Command } from 'shared';
export class StepCommand extends Command {
public constructor(public readonly deltaTimeInSeconds: number) {
super();
}
}

View file

@ -1,35 +1,9 @@
/**
* Hardcoded list of game servers the landing page offers to players.
*
* Each entry is the public origin of a dockerized `doppler-server` instance.
* The join screen polls `<origin>/state` (see `serverInformationEndpoint`) and
* only shows a server once it responds, so listing an offline origin here is
* harmless. Add or remove origins as you deploy more server containers.
*/
// This origin predates the rebrand; update it once the game server is
// redeployed under a doppler subdomain.
const productionServers: Array<string> = ['https://declared.schmelczer.dev'];
/**
* When the page is served from localhost (i.e. the webpack-dev-server), also
* offer the local backend so a `npm start` server can be joined during
* development. The backend's default port is 3000 (see backend/src/options.ts).
* In production the page is served from its own hostname, so this never leaks.
*/
const isDevelopment =
typeof location !== 'undefined' &&
(location.hostname === 'localhost' || location.hostname === '127.0.0.1');
const servers: Array<string> = isDevelopment
export const servers: Array<string> = isDevelopment
? [`http://${location.hostname}:3000`, ...productionServers]
: productionServers;
export abstract class Configuration {
public static async initialize(): Promise<void> {
// Kept async for call-site compatibility; the server list is static now.
}
public static get servers(): Array<string> {
return servers;
}
}

View file

@ -1,120 +1,128 @@
import { settings } from 'shared';
import { Pointer } from './helper/pointer';
import { getDisplayPosition } from './helper/pointer';
export abstract class FeedbackHud {
private static root?: HTMLElement;
private static killfeed?: HTMLElement;
private static elimination?: HTMLElement;
let root: HTMLElement | undefined;
let killfeed: HTMLElement | undefined;
let elimination: HTMLElement | undefined;
private static ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } {
if (!this.root || !this.killfeed) {
this.root = document.createElement('div');
this.root.className = 'feedback-hud';
function ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } {
if (!root || !killfeed) {
root = document.createElement('div');
root.className = 'feedback-hud';
this.killfeed = document.createElement('div');
this.killfeed.className = 'killfeed';
this.root.appendChild(this.killfeed);
killfeed = document.createElement('div');
killfeed.className = 'killfeed';
root.appendChild(killfeed);
document.body.appendChild(this.root);
}
return { root: this.root, killfeed: this.killfeed };
document.body.appendChild(root);
}
return { root, killfeed };
}
private static focusPoint(): { x: number; y: number } {
const cursor = Pointer.getDisplayPosition();
if (cursor) {
return { x: cursor.x, y: cursor.y };
}
return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
function focusPoint(): { x: number; y: number } {
const cursor = getDisplayPosition();
if (cursor) {
return { x: cursor[0], y: cursor[1] };
}
return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
}
private static addTransient(element: HTMLElement, lifetimeMs: number) {
const { root } = this.ensureRoot();
root.appendChild(element);
setTimeout(() => element.parentElement?.removeChild(element), lifetimeMs);
}
function addTransient(el: HTMLElement, lifetimeMs: number) {
const { root: r } = ensureRoot();
r.appendChild(el);
setTimeout(() => el.remove(), lifetimeMs);
}
public static hitMarker(charge = 0) {
const { x, y } = this.focusPoint();
const marker = document.createElement('div');
marker.className =
'hitmarker' + (charge >= settings.chargedHitThreshold ? ' charged' : '');
marker.style.left = `${x}px`;
marker.style.top = `${y}px`;
this.addTransient(marker, 250);
}
function hitMarker(charge = 0) {
const { x, y } = focusPoint();
const marker = document.createElement('div');
marker.className =
'hitmarker' + (charge >= settings.chargedHitThreshold ? ' charged' : '');
marker.style.left = `${x}px`;
marker.style.top = `${y}px`;
addTransient(marker, 250);
}
public static killConfirmed(victimName?: string, streak = 1, charge = 0) {
const { killfeed } = this.ensureRoot();
const charged = charge >= settings.chargedHitThreshold;
function killConfirmed(victimName?: string, streak = 1, charge = 0) {
const { killfeed: kf } = ensureRoot();
const charged = charge >= settings.chargedHitThreshold;
// A quick crimson vignette pulse around the whole frame to punctuate the kill.
const flash = document.createElement('div');
flash.className = 'kill-flash' + (charged ? ' charged' : '');
this.addTransient(flash, 420);
const flash = document.createElement('div');
flash.className = 'kill-flash' + (charged ? ' charged' : '');
addTransient(flash, 420);
const entry = document.createElement('div');
entry.className = 'kill-entry';
entry.innerHTML = `Eliminated <b>${this.escape(victimName ?? 'enemy')}</b>`;
killfeed.insertBefore(entry, killfeed.firstChild);
setTimeout(() => entry.parentElement?.removeChild(entry), 4500);
const entry = document.createElement('div');
entry.className = 'kill-entry';
entry.innerHTML = `Eliminated <b>${escapeHtml(victimName ?? 'enemy')}</b>`;
kf.insertBefore(entry, kf.firstChild);
setTimeout(() => entry.remove(), 4500);
const { x, y } = this.focusPoint();
const popup = document.createElement('div');
popup.className = 'kill-popup' + (charged ? ' charged' : '');
popup.innerHTML = `+${settings.playerKillPoint} <span class="heal">+${settings.playerKillHealthReward}❤</span>`;
popup.style.left = `${x}px`;
popup.style.top = `${y}px`;
this.addTransient(popup, 1200);
const { x, y } = focusPoint();
const popup = document.createElement('div');
popup.className = 'kill-popup' + (charged ? ' charged' : '');
popup.innerHTML = `+${settings.playerKillPoint} <span class="heal">+${settings.playerKillHealthReward}❤</span>`;
popup.style.left = `${x}px`;
popup.style.top = `${y}px`;
addTransient(popup, 1200);
const callout = this.streakName(streak) ?? (charged ? 'Charged Kill!' : undefined);
if (callout) {
const el = document.createElement('div');
el.className = 'streak-callout';
el.innerText = callout;
this.addTransient(el, 1400);
}
}
// Persistent centred overlay shown while the local player is dead and waiting
// to respawn. The countdown itself is the server-driven "Reviving in N…"
// announcement; this makes the death state unmistakable and stays up until
// hideElimination() is called on respawn.
public static showElimination(): void {
if (this.elimination) {
return;
}
const { root } = this.ensureRoot();
const callout = streakName(streak) ?? (charged ? 'Charged Kill!' : undefined);
if (callout) {
const el = document.createElement('div');
el.className = 'elimination';
el.innerHTML =
'<div class="elimination-title">Eliminated</div>' +
'<div class="elimination-sub">Respawning…</div>';
root.appendChild(el);
this.elimination = el;
}
public static hideElimination(): void {
this.elimination?.parentElement?.removeChild(this.elimination);
this.elimination = undefined;
}
private static streakName(streak: number): string | undefined {
switch (streak) {
case 2:
return 'Double Kill!';
case 3:
return 'Triple Kill!';
case 4:
return 'Quad Kill!';
default:
return streak >= 5 ? 'Rampage!' : undefined;
}
}
private static escape(text: string): string {
const div = document.createElement('div');
div.innerText = text;
return div.innerHTML;
el.className = 'streak-callout';
el.innerText = callout;
addTransient(el, 1400);
}
}
function showElimination(): void {
if (elimination) {
return;
}
const { root: r } = ensureRoot();
const el = document.createElement('div');
el.className = 'elimination';
el.innerHTML =
'<div class="elimination-title">Eliminated</div>' +
'<div class="elimination-sub">Respawning…</div>';
r.appendChild(el);
elimination = el;
}
function hideElimination(): void {
elimination?.remove();
elimination = undefined;
}
function resetFeedbackHud(): void {
hideElimination();
root?.remove();
root = undefined;
killfeed = undefined;
}
function streakName(streak: number): string | undefined {
switch (streak) {
case 2:
return 'Double Kill!';
case 3:
return 'Triple Kill!';
case 4:
return 'Quad Kill!';
default:
return streak >= 5 ? 'Rampage!' : undefined;
}
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.innerText = text;
return div.innerHTML;
}
export const FeedbackHud = {
hitMarker,
killConfirmed,
showElimination,
hideElimination,
reset: resetFeedbackHud,
};

View file

@ -21,6 +21,7 @@ import {
Command,
settings,
InputAcknowledgement,
JoinRejectionReason,
} from 'shared';
import { io, Socket } from 'socket.io-client';
import { KeyboardListener } from './commands/keyboard-listener';
@ -32,34 +33,67 @@ import { GameObjectContainer } from './objects/game-object-container';
import parser from 'socket.io-msgpack-parser';
import { CharacterShape } from './shapes/character-shape';
import { PlanetShape } from './shapes/planet-shape';
import { RenderCommand } from './commands/types/render';
import { StepCommand } from './commands/types/step';
import { serverTimeline } from './helper/server-timeline';
import { localCharacterPredictor } from './helper/prediction/local-character-predictor';
import { centeredTransform } from './helper/centered-transform';
import {
localCharacterPredictor,
setFrameTimeMs,
} from './helper/prediction/local-character-predictor';
import { Tutorial } from './tutorial';
import { Scoreboard } from './scoreboard';
import { Minimap } from './minimap';
import { ScreenShake } from './screen-shake';
import { FeedbackHud } from './feedback-hud';
const maximumReconnectionAttempts = 10;
export class Game extends CommandReceiver {
public gameObjects = new GameObjectContainer(this);
public renderer?: Renderer;
public rejectionReason?: JoinRejectionReason;
public readonly started: Promise<void>;
private socket!: Socket;
private isBetweenGames = false;
public started: Promise<void>;
private resolveStarted!: () => unknown;
private keyboardListener: KeyboardListener;
private mouseListener: MouseListener;
private touchListener: TouchListener;
private scoreboard = new Scoreboard();
private minimap = new Minimap();
private announcementText = document.createElement('h2');
private keystoneArrow?: HTMLElement;
private socketReceiver!: CommandSocket;
private tutorial!: Tutorial;
private resolveStarted!: () => void;
private isBetweenGames = false;
private isActive = true;
private readonly keyboardListener: KeyboardListener;
private readonly mouseListener: MouseListener;
private readonly touchListener: TouchListener;
private readonly scoreboard = new Scoreboard();
private readonly minimap = new Minimap();
private readonly announcementText = document.createElement('h2');
private keystoneArrow?: HTMLElement;
private connectionBanner?: HTMLElement;
private reconnectToken?: string;
private lastAspectRatio?: number;
private lastGameState?: UpdateGameState;
private lastMinimap?: UpdateMinimap;
private lastAnnouncementText = '';
private timeSinceLastAnnouncement = 0;
private framesSinceLastLayoutUpdate = 0;
protected commandExecutors: CommandExecutors = {
[ServerAnnouncement.name]: (c: ServerAnnouncement) => {
this.lastAnnouncementText = c.text;
this.timeSinceLastAnnouncement = 0;
},
[UpdateGameState.name]: (c: UpdateGameState) => (this.lastGameState = c),
[InputAcknowledgement.name]: (c: InputAcknowledgement) =>
localCharacterPredictor.acknowledge(
c.clientTimeMs,
c.movement,
c.lastLeapClientTimeMs,
c.ackAgeMs,
),
[GameEndCommand.name]: () => (localCharacterPredictor.enabled = false),
[UpdateMinimap.name]: (c: UpdateMinimap) => (this.lastMinimap = c),
[GameStartCommand.name]: () => this.initialize(),
};
constructor(
private readonly playerDecision: PlayerDecision,
@ -70,9 +104,17 @@ export class Game extends CommandReceiver {
this.started = new Promise((r) => (this.resolveStarted = r));
this.announcementText.className = 'announcement';
this.keyboardListener = new KeyboardListener();
this.mouseListener = new MouseListener(this.canvas, this);
this.touchListener = new TouchListener(this.canvas, this.overlay, this);
const onInput = (c: Command) => {
this.socketReceiver.queue(c);
this.tutorial.handleCommand(c);
};
this.keyboardListener = new KeyboardListener(onInput);
this.mouseListener = new MouseListener(this.canvas, this, onInput);
this.touchListener = new TouchListener(this.canvas, this.overlay, this, onInput);
}
protected defaultCommandExecutor(c: Command) {
this.gameObjects.handleCommand(c);
}
private initialize() {
@ -81,136 +123,188 @@ export class Game extends CommandReceiver {
this.socket?.close();
serverTimeline.reset();
localCharacterPredictor.reset();
// Clear any leftover shake/zoom so a kill at the end of one match can't bleed
// its camera impact into the next.
localCharacterPredictor.enabled = true;
ScreenShake.reset();
this.gameObjects.reset();
this.gameObjects = new GameObjectContainer(this);
this.overlay.innerHTML = '';
this.keystoneArrow = undefined;
this.connectionBanner = undefined;
this.lastMinimap = undefined;
this.isEnding = false;
this.lastAnnouncementText = '';
this.overlay.appendChild(this.scoreboard.element);
this.overlay.appendChild(this.minimap.element);
this.announcementText.innerText = '';
this.timeScaling = 1;
this.overlay.appendChild(this.announcementText);
this.overlay.append(
this.scoreboard.element,
this.minimap.element,
this.announcementText,
);
this.tutorial = new Tutorial(this.overlay);
this.socket = io(this.playerDecision.server, {
reconnectionDelayMax: 10000,
// Must be finite, otherwise `reconnect_failed` never fires.
reconnectionAttempts: maximumReconnectionAttempts,
transports: ['websocket'],
forceNew: true,
parser,
} as any);
this.socketReceiver = new CommandSocket(this.socket);
// In socket.io-client v4 reconnection events are emitted by the Manager
// (`socket.io`), not the Socket itself.
this.socket.io.on('reconnect_attempt', () => {
this.socket.io.opts.transports = ['polling', 'websocket'];
});
this.socket.on('disconnect', () => {
if (!this.isBetweenGames) {
this.destroy();
this.showConnectionBanner('Reconnecting…');
}
});
this.socket.on(TransportEvents.Ping, () => {
this.socket.emit(TransportEvents.Pong);
// Fires for every (re)connection: a reconnect is a brand-new server-side player.
this.socket.on('connect', () => {
if (this.isBetweenGames) {
return;
}
this.hideConnectionBanner();
serverTimeline.reset();
localCharacterPredictor.reset();
this.gameObjects.reset();
this.socketReceiver.reset();
this.socket.emit(TransportEvents.PlayerJoining, {
...this.playerDecision,
reconnectToken: this.reconnectToken,
});
if (this.lastAspectRatio !== undefined) {
this.socketReceiver.queue(new SetAspectRatioActionCommand(this.lastAspectRatio));
}
});
this.socket.io.on('reconnect_failed', () => {
this.showConnectionBanner('Connection lost');
this.destroy();
});
this.socket.on(TransportEvents.PlayerJoined, (token: string) => {
this.reconnectToken = typeof token === 'string' ? token : undefined;
this.hideConnectionBanner();
});
this.socket.on(TransportEvents.JoinRejected, (reason: JoinRejectionReason) => {
this.rejectionReason = reason;
this.showConnectionBanner(Game.rejectionText(reason));
this.destroy();
});
this.socket.on(TransportEvents.Ping, (nonce: unknown) => {
this.socket.emit(TransportEvents.Pong, nonce);
});
this.socket.on(TransportEvents.ServerToPlayer, (serializedCommands: string) => {
const commands: Array<Command> = deserialize(serializedCommands);
commands.forEach((c) => this.handleCommand(c));
try {
const commands: Array<Command> = deserialize(serializedCommands);
commands.forEach((c) => {
try {
this.handleCommand(c);
} catch (e) {
console.error('Failed to apply a server command', e);
}
});
} catch (e) {
console.error('Dropped an undecodable server message', e);
}
});
this.socketReceiver = new CommandSocket(this.socket);
// The tutorial listens to the same input streams as the socket, so its
// stages clear off the player's own commands without any server involvement.
this.keyboardListener.clearSubscribers();
this.keyboardListener.subscribe(this.socketReceiver);
this.keyboardListener.subscribe(this.tutorial);
this.mouseListener.clearSubscribers();
this.mouseListener.subscribe(this.socketReceiver);
this.mouseListener.subscribe(this.tutorial);
this.touchListener.clearSubscribers();
this.touchListener.subscribe(this.socketReceiver);
this.touchListener.subscribe(this.tutorial);
this.isBetweenGames = false;
this.socket.emit(TransportEvents.PlayerJoining, this.playerDecision);
}
protected defaultCommandExecutor(c: Command) {
this.gameObjects.handleCommand(c);
}
private lastGameState?: UpdateGameState;
private isEnding = false;
private timeScaling = 1;
private lastAnnouncementText = '';
protected commandExecutors: CommandExecutors = {
[ServerAnnouncement.type]: (c: ServerAnnouncement) => {
this.lastAnnouncementText = c.text;
this.timeSinceLastAnnouncement = 0;
},
[UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c),
[InputAcknowledgement.type]: (c: InputAcknowledgement) =>
localCharacterPredictor.acknowledge(
c.clientTimeMs,
c.bodyVelocity,
c.lastLeapClientTimeMs,
),
[GameEndCommand.type]: () => (this.isEnding = true),
[UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c),
[GameStartCommand.type]: this.initialize.bind(this),
};
private lastMinimap?: UpdateMinimap;
public async start(): Promise<void> {
const noiseTexture = await renderNoise([256, 256], 2, 1);
setFrameTimeMs(0);
try {
this.initialize();
this.resolveStarted();
let renderError: unknown;
const noiseTexture = await renderNoise([256, 256], 2, 1);
this.initialize();
await runAnimation(
this.canvas,
[
PlanetShape.descriptor,
CharacterShape.descriptor,
{
...CircleLight.descriptor,
shaderCombinationSteps: [0, 1, 2, 4, 8, 16],
await runAnimation(
this.canvas,
[
PlanetShape.descriptor,
CharacterShape.descriptor,
{
...CircleLight.descriptor,
shaderCombinationSteps: [0, 1, 2, 4, 8, 16],
},
],
(renderer, currentTime, deltaTime) => {
try {
return this.gameLoop(renderer, currentTime, deltaTime);
} catch (error) {
renderError = error;
return false;
}
},
],
this.gameLoop.bind(this),
{
shadowTraceCount: 16,
paletteSize: settings.paletteDim.length,
colorPalette: settings.paletteDim,
enableHighDpiRendering: true,
lightCutoffDistance: settings.lightCutoffDistance,
lightOverlapReduction: settings.lightOverlapReduction,
textures: {
noiseTexture: {
source: noiseTexture,
overrides: {
maxFilter: FilteringOptions.LINEAR,
wrapS: WrapOptions.MIRRORED_REPEAT,
wrapT: WrapOptions.MIRRORED_REPEAT,
{
shadowTraceCount: 16,
paletteSize: settings.paletteDim.length,
colorPalette: settings.paletteDim,
enableHighDpiRendering: true,
lightCutoffDistance: settings.lightCutoffDistance,
lightOverlapReduction: settings.lightOverlapReduction,
textures: {
noiseTexture: {
source: noiseTexture,
overrides: {
maxFilter: FilteringOptions.LINEAR,
wrapS: WrapOptions.MIRRORED_REPEAT,
wrapT: WrapOptions.MIRRORED_REPEAT,
},
},
},
},
},
);
this.socket.close();
this.overlay.innerHTML = '';
this.keyboardListener.destroy();
this.mouseListener.destroy();
this.touchListener.destroy();
);
if (renderError) {
throw renderError;
}
} finally {
this.resolveStarted();
this.socket?.close();
this.gameObjects.reset();
this.overlay.innerHTML = '';
this.hideConnectionBanner();
FeedbackHud.reset();
this.keyboardListener.destroy();
this.mouseListener.destroy();
this.touchListener.destroy();
}
}
public static rejectionText(reason: JoinRejectionReason): string {
switch (reason) {
case JoinRejectionReason.ServerFull:
return 'That server is full — pick another';
case JoinRejectionReason.RoundEnding:
return 'That round is just finishing — try again in a moment';
case JoinRejectionReason.AlreadyJoined:
return 'Already joined on this connection';
default:
return 'The server refused the connection';
}
}
private showConnectionBanner(text: string) {
if (!this.connectionBanner) {
this.connectionBanner = document.createElement('div');
this.connectionBanner.className = 'connection-banner';
this.overlay.appendChild(this.connectionBanner);
}
this.connectionBanner.innerText = text;
this.connectionBanner.style.display = 'block';
}
private hideConnectionBanner() {
if (this.connectionBanner) {
this.connectionBanner.style.display = 'none';
}
}
public displayToWorldCoordinates(p: vec2): vec2 {
@ -218,37 +312,35 @@ export class Game extends CommandReceiver {
}
public aspectRatioChanged(aspectRatio: number) {
this.socketReceiver.handleCommand(new SetAspectRatioActionCommand(aspectRatio));
this.lastAspectRatio = aspectRatio;
this.socketReceiver.queue(new SetAspectRatioActionCommand(aspectRatio));
}
private isActive = true;
public destroy() {
this.isActive = false;
}
private timeSinceLastAnnouncement = 0;
private framesSinceLastLayoutUpdate = 0;
public resendMovement() {
this.keyboardListener.resendMovement();
this.touchListener.resendMovement();
}
private gameLoop(
renderer: Renderer,
_: DOMHighResTimeStamp,
currentTime: DOMHighResTimeStamp,
deltaTime: DOMHighResTimeStamp,
): boolean {
this.resolveStarted();
this.renderer = renderer;
// The frame timestamp, not performance.now(): dispatch jitter would land in the replay window.
setFrameTimeMs(currentTime);
deltaTime /= 1000;
// Decay the camera impact effects on raw wall-clock time, before any of the
// end-game slow-motion scaling below. These only adjust the rendered view,
// never the simulation, so they stay decoupled from prediction and netcode.
// Both run on wall-clock time: the end-game slow motion is already baked into the snapshots.
ScreenShake.step(deltaTime);
// Stepped before the end-game time scaling on purpose: the slow motion is
// already baked into the snapshots the server sends, so the playback
// cursor itself must keep running on wall-clock time.
serverTimeline.step(deltaTime);
let shouldChangeLayout = false;
if (++this.framesSinceLastLayoutUpdate > 1) {
shouldChangeLayout = true;
const shouldChangeLayout = ++this.framesSinceLastLayoutUpdate > 1;
if (shouldChangeLayout) {
this.framesSinceLastLayoutUpdate = 0;
this.draw();
}
@ -259,51 +351,38 @@ export class Game extends CommandReceiver {
this.lastAnnouncementText = '';
}
if (this.isEnding) {
this.timeScaling *= Math.pow(settings.endGameDeltaScaling, deltaTime);
deltaTime /= this.timeScaling;
}
this.renderer = renderer;
this.gameObjects.handleCommand(new StepCommand(deltaTime));
this.gameObjects.handleCommand(
new RenderCommand(this.renderer, this.overlay, shouldChangeLayout),
);
this.touchListener.update(deltaTime);
this.gameObjects.step(deltaTime);
this.gameObjects.render(renderer, this.overlay, shouldChangeLayout);
this.touchListener.update();
this.tutorial.step(this.gameObjects);
this.socketReceiver.sendQueuedCommands();
return this.isActive;
}
private draw() {
// Before any style writes this frame, so the read cannot force a synchronous layout.
this.minimap.measure();
if (this.lastGameState) {
// The local player's team is read off the main character once it exists.
this.scoreboard.update(this.lastGameState, this.gameObjects.player?.team);
this.scoreboard.update(this.lastGameState, this.gameObjects.localPlayer?.team);
}
this.minimap.update(
this.gameObjects.localPlayerPosition,
this.gameObjects.localPlayer?.position,
this.lastMinimap?.players ?? [],
);
this.handleKeystoneArrow();
this.announcementText.innerHTML = this.lastAnnouncementText;
if (this.announcementText.innerHTML !== this.lastAnnouncementText) {
this.announcementText.innerHTML = this.lastAnnouncementText;
}
}
// Points an off-screen chevron toward the keystone "Heart" planet, tinted by
// who currently holds it, so the match's focal objective is always findable.
private handleKeystoneArrow() {
if (!this.renderer) {
return;
}
const keystone = this.gameObjects.planets.find((p) => p.isKeystone);
if (!keystone) {
if (!this.renderer || !keystone) {
if (this.keystoneArrow) {
this.keystoneArrow.style.display = 'none';
}
@ -315,47 +394,38 @@ export class Game extends CommandReceiver {
this.overlay.appendChild(this.keystoneArrow);
}
const width = this.renderer.canvasSize.x;
const height = this.renderer.canvasSize.y;
const [width, height] = this.renderer.canvasSize;
const display = this.renderer.worldToDisplayCoordinates(keystone.center);
const margin = 48;
const onScreen =
display.x >= margin &&
display.x <= width - margin &&
display.y >= margin &&
display.y <= height - margin;
const control = Math.abs(keystone.ownership - 0.5);
const team =
control < settings.planetControlThreshold
? 'neutral'
: keystone.ownership < 0.5
? 'blue'
: 'red';
this.keystoneArrow.className = 'keystone-arrow ' + team;
display[0] >= margin &&
display[0] <= width - margin &&
display[1] >= margin &&
display[1] <= height - margin;
this.keystoneArrow.className = 'keystone-arrow ' + keystone.team;
this.keystoneArrow.style.display = onScreen ? 'none' : 'block';
if (onScreen) {
this.keystoneArrow.style.display = 'none';
return;
}
this.keystoneArrow.style.display = 'block';
const center = vec2.fromValues(width / 2, height / 2);
const dir = vec2.fromValues(display.x - center.x, display.y - center.y);
const angle = Math.atan2(dir.y, dir.x);
const dx = display[0] - width / 2;
const dy = display[1] - height / 2;
const angle = Math.atan2(dy, dx);
const aspectRatio = width / height;
const directionRatio = dir.x / dir.y;
let deltaX: number, deltaY: number;
if (aspectRatio < Math.abs(directionRatio)) {
deltaX = (width / 2 - margin) * Math.sign(dir.x);
deltaY = deltaX / directionRatio;
if (width / height < Math.abs(dx / dy)) {
deltaX = (width / 2 - margin) * Math.sign(dx);
deltaY = (deltaX * dy) / dx;
} else {
deltaY = (height / 2 - margin) * Math.sign(dir.y);
deltaX = deltaY * directionRatio;
deltaY = (height / 2 - margin) * Math.sign(dy);
deltaX = (deltaY * dx) / dy;
}
const p = vec2.add(center, center, vec2.fromValues(deltaX, deltaY));
this.keystoneArrow.style.transform = `translateX(${p.x}px) translateY(${p.y}px) translateX(-50%) translateY(-50%) rotate(${angle + Math.PI / 2}rad)`;
this.keystoneArrow.style.transform = centeredTransform(
width / 2 + deltaX,
height / 2 + deltaY,
` rotate(${angle + Math.PI / 2}rad)`,
);
}
}

View file

@ -0,0 +1,2 @@
export const centeredTransform = (x: number, y: number, extra = ''): string =>
`translateX(${x}px) translateY(${y}px) translateX(-50%) translateY(-50%)${extra}`;

View file

@ -18,32 +18,25 @@ export const handleFullScreen = (
};
showButtons();
let currentWindowHeight = innerHeight;
const followToggle = () => {
SoundHandler.play(Sounds.click);
showButtons();
currentWindowHeight = innerHeight;
};
document.addEventListener('fullscreenchange', showButtons);
const triggerToggle = async () => {
await (isInFullScreen()
? document.exitFullscreen()
: document.body.requestFullscreen());
followToggle();
try {
await (isInFullScreen()
? document.exitFullscreen()
: document.body.requestFullscreen());
SoundHandler.play(Sounds.click);
} catch {
// The browser may deny fullscreen (for example, when embedded).
}
};
addEventListener('keydown', (e) => {
if (e.key === 'F11') {
triggerToggle();
e.preventDefault();
}
});
addEventListener('resize', () => {
if (isInFullScreen() && currentWindowHeight > innerHeight) {
followToggle();
if (!e.repeat) {
void triggerToggle();
}
}
});

View file

@ -1,7 +0,0 @@
export const hide = (e: HTMLElement, useDisplay = false) => {
if (useDisplay) {
e.style.display = 'none';
} else {
e.style.visibility = 'hidden';
}
};

Some files were not shown because too many files have changed in this diff Show more