Improve netcode and simplify (#4)
- 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:
parent
5d854440ec
commit
93a7fe01ab
50 changed files with 15073 additions and 4754 deletions
54
frontend/package-lock.json
generated
54
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
6
frontend/src/custom.d.ts
vendored
6
frontend/src/custom.d.ts
vendored
|
|
@ -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 } };
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
29
frontend/src/scripts/commands/input-generator.ts
Normal file
29
frontend/src/scripts/commands/input-generator.ts
Normal 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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
import { Command } from 'shared';
|
||||
|
||||
export class BeforeDestroyCommand extends Command {
|
||||
public constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { Command } from 'shared';
|
||||
|
||||
export class StepCommand extends Command {
|
||||
public constructor(public readonly deltaTimeInSeconds: number) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
frontend/src/scripts/helper/centered-transform.ts
Normal file
2
frontend/src/scripts/helper/centered-transform.ts
Normal 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}`;
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue