Improve gameplay
This commit is contained in:
parent
7412bc8af5
commit
793d9a81e8
27 changed files with 275 additions and 226 deletions
47
frontend/package-lock.json
generated
47
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": "^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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
58
frontend/src/scripts/charge-indicator.ts
Normal file
58
frontend/src/scripts/charge-indicator.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
13
frontend/src/scripts/helper/pointer.ts
Normal file
13
frontend/src/scripts/helper/pointer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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]) => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue