Improve gameplay

This commit is contained in:
Andras Schmelczer 2026-06-11 20:26:44 +01:00
parent 7412bc8af5
commit 793d9a81e8
27 changed files with 275 additions and 226 deletions

View file

@ -23,7 +23,7 @@
"resize-observer-polyfill": "^1.5.1",
"sass": "^1.100.0",
"sass-loader": "^17.0.0",
"sdf-2d": "^0.7.6",
"sdf-2d": "file:../../sdf-2d",
"shared": "file:../shared",
"socket.io-client": "^4.8.3",
"socket.io-msgpack-parser": "^3.0.2",
@ -36,6 +36,40 @@
"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,
@ -5409,15 +5443,8 @@
}
},
"node_modules/sdf-2d": {
"version": "0.7.6",
"resolved": "https://registry.npmjs.org/sdf-2d/-/sdf-2d-0.7.6.tgz",
"integrity": "sha512-aBJUjwYjWP+/fSvLHH6vhpFyWeSJNCSEHpE0XBX69s2bQlo/NOUr3nq/KuhRZ6A3FeXrGU4lzyurHJ1N8f4rFg==",
"dev": true,
"license": "ISC",
"dependencies": {
"gl-matrix": "^3.3.0",
"resize-observer-polyfill": "^1.5.1"
}
"resolved": "../../sdf-2d",
"link": true
},
"node_modules/select-hose": {
"version": "2.0.0",

View file

@ -37,7 +37,7 @@
"resize-observer-polyfill": "^1.5.1",
"sass": "^1.100.0",
"sass-loader": "^17.0.0",
"sdf-2d": "^0.7.6",
"sdf-2d": "^0.8.0",
"shared": "file:../shared",
"socket.io-client": "^4.8.3",
"socket.io-msgpack-parser": "^3.0.2",

View file

@ -0,0 +1,58 @@
import { holdDurationToCharge } from 'shared';
import { Pointer } from './helper/pointer';
export abstract class ChargeIndicator {
private static element?: HTMLElement;
private static heldSince = 0;
private static raf = 0;
private static followPointer = false;
public static begin(x: number, y: number, followPointer = false) {
this.end();
const element = document.createElement('div');
element.className = 'charge-ring';
element.style.left = `${x}px`;
element.style.top = `${y}px`;
document.body.appendChild(element);
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);
};
}

View file

@ -30,10 +30,12 @@ import { CommandSocket } from './commands/command-socket';
import { PlayerDecision } from './join-form-handler';
import { GameObjectContainer } from './objects/game-object-container';
import parser from 'socket.io-msgpack-parser';
import { BlobShape } from './shapes/blob-shape';
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 { Tutorial } from './tutorial';
import { Scoreboard } from './scoreboard';
export class Game extends CommandReceiver {
public gameObjects = new GameObjectContainer(this);
@ -48,12 +50,11 @@ export class Game extends CommandReceiver {
private mouseListener: MouseListener;
private touchListener: TouchListener;
private declaPlanetCountElement = document.createElement('div');
private redPlanetCountElement = document.createElement('div');
private scoreboard = new Scoreboard();
private announcementText = document.createElement('h2');
private progressBar = document.createElement('div');
private arrows: { [id: number]: HTMLElement } = {};
private socketReceiver!: CommandSocket;
private tutorial!: Tutorial;
constructor(
private readonly playerDecision: PlayerDecision,
@ -63,9 +64,6 @@ export class Game extends CommandReceiver {
super();
this.started = new Promise((r) => (this.resolveStarted = r));
this.announcementText.className = 'announcement';
this.progressBar.className = 'planet-progress';
this.progressBar.appendChild(this.declaPlanetCountElement);
this.progressBar.appendChild(this.redPlanetCountElement);
this.keyboardListener = new KeyboardListener();
this.mouseListener = new MouseListener(this.canvas, this);
@ -78,12 +76,14 @@ export class Game extends CommandReceiver {
this.socket?.close();
this.gameObjects = new GameObjectContainer(this);
this.overlay.innerHTML = '';
this.arrows = {};
this.isEnding = false;
this.lastAnnouncementText = '';
this.overlay.appendChild(this.progressBar);
this.overlay.appendChild(this.scoreboard.element);
this.announcementText.innerText = '';
this.timeScaling = 1;
this.overlay.appendChild(this.announcementText);
this.tutorial = new Tutorial(this.overlay);
this.socket = io(this.playerDecision.server, {
reconnectionDelayMax: 10000,
@ -114,12 +114,17 @@ export class Game extends CommandReceiver {
});
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;
@ -189,9 +194,8 @@ export class Game extends CommandReceiver {
clamp(p.x, arrowPadding, width - arrowPadding),
clamp(height - p.y, arrowPadding, height - arrowPadding),
);
e.style.transform = `translateX(${p.x}px) translateY(${
p.y
}px) translateX(-50%) translateY(-50%) rotate(${-angle + Math.PI / 2}rad) `;
e.style.transform = `translateX(${p.x}px) translateY(${p.y
}px) translateX(-50%) translateY(-50%) rotate(${-angle + Math.PI / 2}rad) `;
});
for (const id in this.arrows) {
@ -214,7 +218,7 @@ export class Game extends CommandReceiver {
this.canvas,
[
PlanetShape.descriptor,
BlobShape.descriptor,
CharacterShape.descriptor,
{
...CircleLight.descriptor,
shaderCombinationSteps: [0, 1, 2, 4, 8, 16],
@ -223,10 +227,11 @@ export class Game extends CommandReceiver {
this.gameLoop.bind(this),
{
shadowTraceCount: 16,
paletteSize: settings.palette.length,
colorPalette: settings.palette,
paletteSize: settings.paletteDim.length,
colorPalette: settings.paletteDim,
enableHighDpiRendering: true,
lightCutoffDistance: settings.lightCutoffDistance,
lightOverlapReduction: settings.lightOverlapReduction,
textures: {
noiseTexture: {
source: noiseTexture,
@ -276,7 +281,9 @@ export class Game extends CommandReceiver {
this.draw();
}
if ((this.timeSinceLastAnnouncement += deltaTime) > 0.5) {
if (
(this.timeSinceLastAnnouncement += deltaTime) > settings.announcementVisibleSeconds
) {
this.lastAnnouncementText = '';
}
@ -292,6 +299,10 @@ export class Game extends CommandReceiver {
new RenderCommand(this.renderer, this.overlay, shouldChangeLayout),
);
this.touchListener.update(deltaTime);
this.tutorial.step(this.gameObjects);
this.socketReceiver.sendQueuedCommands();
return this.isActive;
@ -299,10 +310,8 @@ export class Game extends CommandReceiver {
private draw() {
if (this.lastGameState) {
this.declaPlanetCountElement.style.width =
(this.lastGameState.declaCount / this.lastGameState.limit) * 50 + '%';
this.redPlanetCountElement.style.width =
(this.lastGameState.redCount / this.lastGameState.limit) * 50 + '%';
// The local player's team is read off the main character once it exists.
this.scoreboard.update(this.lastGameState, this.gameObjects.player?.team);
}
if (this.lastOtherPlayerDirections) {

View file

@ -0,0 +1,13 @@
import { vec2 } from 'gl-matrix';
export class Pointer {
private static displayPosition: vec2 | null = null;
public static setDisplayPosition(x: number, y: number): void {
Pointer.displayPosition = vec2.fromValues(x, y);
}
public static getDisplayPosition(): vec2 | null {
return Pointer.displayPosition;
}
}

View file

@ -102,7 +102,7 @@ export class JoinFormHandler {
private removeServer(server: ServerChooserOption) {
this.servers = this.servers.filter((s) => s !== server);
if (this.servers.length) {
if (!this.servers.length) {
this.joinButton.disabled = true;
}
}
@ -143,16 +143,17 @@ class ServerChooserOption {
this.setServerInfoLabelText();
this.socket = io(url, {
reconnection: false,
reconnection: true,
reconnectionAttempts: 5,
timeout: 4000,
parser,
} as any);
// `connect_timeout` was removed in socket.io-client v3+; connection
// timeouts now surface through `connect_error` (reconnection is disabled).
this.socket.on('connect_error', this.destroy.bind(this));
this.socket.on('disconnect', this.destroy.bind(this));
this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates);
this.socket.io.on('reconnect_failed', this.destroy.bind(this));
this.socket.on('connect', () =>
this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates),
);
this.socket.on(
TransportEvents.ServerInfoUpdate,
([playerCount, gameState]: [number, number]) => {

View file

@ -1,18 +1,36 @@
import { vec2, vec3 } from 'gl-matrix';
import { CircleLight } from 'sdf-2d';
import { CommandExecutors, Id, LampBase } from 'shared';
import { CommandExecutors, Id, LampBase, mixRgb, settings } from 'shared';
import { RenderCommand } from '../../commands/types/render';
import { StepCommand } from '../../commands/types/step';
export class LampView extends LampBase {
private light: CircleLight;
private targetColor: vec3;
private targetLightness: number;
protected commandExecutors: CommandExecutors = {
[RenderCommand.type]: this.draw.bind(this),
[StepCommand.type]: this.step.bind(this),
};
constructor(id: Id, center: vec2, color: vec3, lightness: number) {
super(id, center, color, lightness);
this.light = new CircleLight(center, color, lightness);
this.light = new CircleLight(vec2.clone(center), vec3.clone(color), lightness);
this.targetColor = vec3.clone(color);
this.targetLightness = lightness;
}
public setLight(color: vec3, lightness: number) {
this.targetColor = vec3.clone(color);
this.targetLightness = lightness;
}
private step({ deltaTimeInSeconds }: StepCommand): void {
const t = 1 - Math.exp(-deltaTimeInSeconds / settings.lampLerpSeconds);
this.light.color = mixRgb(this.light.color, this.targetColor, t);
this.light.intensity += (this.targetLightness - this.light.intensity) * t;
}
private draw({ renderer }: RenderCommand): void {

View file

@ -48,7 +48,10 @@ export class ProjectileView extends ProjectileBase {
this.center = this.centerExtrapolator.getValue(deltaTimeInSeconds);
this.light.center = this.center;
this.light.intensity = (0.15 * this.strength) / settings.projectileMaxStrength;
this.light.intensity = Math.min(
0.1,
(0.15 * this.strength) / settings.projectileMaxStrength,
);
}
private draw({ renderer }: RenderCommand): void {

View file

@ -1,125 +0,0 @@
import { mat2d, vec2 } from 'gl-matrix';
import { Drawable, DrawableDescriptor } from 'sdf-2d';
import { Circle } from 'shared';
export class BlobShape extends Drawable {
public static descriptor: DrawableDescriptor = {
sdf: {
shader: `
uniform vec2 headCenters[BLOB_COUNT];
uniform vec2 leftFootCenters[BLOB_COUNT];
uniform vec2 rightFootCenters[BLOB_COUNT];
uniform float headRadii[BLOB_COUNT];
uniform float footRadii[BLOB_COUNT];
uniform int blobColors[BLOB_COUNT];
float blobSmoothMin(float a, float b)
{
const highp float k = 300.0;
highp float res = exp2(-k * a) + exp2(-k * b);
return -log2(res) / k;
}
float circleDistance(vec2 circleCenter, float radius, vec2 target) {
return distance(target, circleCenter) - radius;
}
float blobMinDistance(vec2 target, out vec4 color) {
float minDistance = 1000.0;
for (int i = 0; i < BLOB_COUNT; i++) {
float headDistance = circleDistance(headCenters[i], headRadii[i], target);
float leftFootDistance = circleDistance(leftFootCenters[i], footRadii[i], target);
float rightFootDistance = circleDistance(rightFootCenters[i], footRadii[i], target);
float res = min(
blobSmoothMin(headDistance, leftFootDistance),
blobSmoothMin(headDistance, rightFootDistance)
);
vec2 leftEyeOffset = vec2(-headRadii[i] * 0.35, headRadii[i] * 0.2);
vec2 rightEyeOffset = vec2(headRadii[i] * 0.35, headRadii[i] * 0.2);
float eyeDistance = min(
circleDistance(headCenters[i] + rightEyeOffset, headRadii[i] * 0.25, target),
circleDistance(headCenters[i] + leftEyeOffset, headRadii[i] * 0.25, target)
);
eyeDistance = max(
eyeDistance,
-circleDistance(headCenters[i] + leftEyeOffset + vec2(0, -headRadii[i] * 0.175), headRadii[i] * 0.2, target)
);
eyeDistance = max(
eyeDistance,
-circleDistance(headCenters[i] + rightEyeOffset + vec2(0, -headRadii[i] * 0.175), headRadii[i] * 0.2, target)
);
if (res < minDistance) {
minDistance = res;
color = eyeDistance < 0.0 ? vec4(1.0) : readFromPalette(blobColors[i]);
}
}
return minDistance;
}
`,
distanceFunctionName: 'blobMinDistance',
},
propertyUniformMapping: {
footRadius: 'footRadii',
headRadius: 'headRadii',
rightFootCenter: 'rightFootCenters',
leftFootCenter: 'leftFootCenters',
headCenter: 'headCenters',
color: 'blobColors',
},
uniformCountMacroName: 'BLOB_COUNT',
shaderCombinationSteps: [0, 1, 2, 8],
empty: new BlobShape(0),
};
protected head!: Circle;
protected leftFoot!: Circle;
protected rightFoot!: Circle;
public constructor(private readonly color: number) {
super();
const circle = new Circle(vec2.create(), 200);
this.setCircles([circle, circle, circle]);
}
public setCircles([head, leftFoot, rightFoot]: [Circle, Circle, Circle]) {
this.head = head;
this.leftFoot = leftFoot;
this.rightFoot = rightFoot;
}
public minDistance(target: vec2): number {
return Math.min(
this.head.distance(target),
this.leftFoot.distance(target),
this.rightFoot.distance(target),
);
}
protected getObjectToSerialize(transform2d: mat2d, transform1d: number): any {
return {
headCenter: vec2.transformMat2d(vec2.create(), this.head.center, transform2d),
leftFootCenter: vec2.transformMat2d(
vec2.create(),
this.leftFoot.center,
transform2d,
),
rightFootCenter: vec2.transformMat2d(
vec2.create(),
this.rightFoot.center,
transform2d,
),
headRadius: this.head.radius * transform1d,
footRadius: this.leftFoot.radius * transform1d,
color: this.color,
};
}
}

View file

@ -137,12 +137,6 @@ module.exports = (env, argv) => {
},
resolve: {
extensions: ['.ts', '.js', '.json'],
alias: {
// sdf-2d's package.json `exports` only declares an `import` condition,
// which webpack 5 cannot resolve for the production (require) build.
// Point straight at its entry to bypass package exports resolution.
'sdf-2d$': path.resolve(__dirname, 'node_modules/sdf-2d/lib/main.js'),
},
},
};
};