Modernise & make fun #3

Merged
andras merged 18 commits from asch/modernise into main 2026-06-21 10:43:50 +01:00
27 changed files with 275 additions and 226 deletions
Showing only changes of commit 793d9a81e8 - Show all commits

View file

@ -46,5 +46,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/state',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
ENTRYPOINT ["node", "dist/main.js"]
# Override these to tune the server, e.g. `--name`, `--playerLimit`, `--worldSize`.
# Override these to tune the server, e.g. `--name`, `--playerLimit`, `--scoreLimit`.
CMD ["--port", "3000"]

View file

@ -6,11 +6,11 @@ import { PhysicalContainer } from './physics/containers/physical-container';
import { evaluateSdf } from './physics/functions/evaluate-sdf';
import { Physical } from './physics/physicals/physical';
export const createWorld = (objectContainer: PhysicalContainer, worldRadius: number) => {
export const createWorld = (objectContainer: PhysicalContainer) => {
const objects: Array<Physical> = [];
const lights: Array<Physical> = [];
for (let r = 0; r < worldRadius; r += settings.radiusSteps) {
for (let r = 0; r < settings.worldRadius; r += settings.radiusSteps) {
const circumference = 2 * Math.PI * r;
const stepCount = circumference * settings.objectsOnCircleLength;
for (let rad = 0; rad < 2 * Math.PI; rad += (2 * Math.PI) / stepCount) {
@ -67,8 +67,29 @@ export const createWorld = (objectContainer: PhysicalContainer, worldRadius: num
}
}
}
console.info('Generated planet count', objects.length);
console.info('Generated light count', lights.length);
console.info(`Generated ${objects.length} planets`);
console.info(`Generated ${lights.length} light`);
// Associate each lamp with its NEAREST planet, so a planet can repaint "its"
// lamps to the owning team's colour when it flips. Lamps are already placed by
// proximity during world-gen, so the nearest planet is the one whose capture
// they should advertise. Distances use the planet SDF (negative inside), which
// is exactly the "closest planet" metric we want.
const planets = objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
lights
.filter((l): l is LampPhysical => l instanceof LampPhysical)
.forEach((lamp) => {
let nearest: PlanetPhysical | undefined;
let nearestDistance = Infinity;
planets.forEach((planet) => {
const distance = planet.distance(lamp.center);
if (distance < nearestDistance) {
nearestDistance = distance;
nearest = planet;
}
});
nearest?.addLamp(lamp);
});
[...objects, ...lights].forEach((o) => objectContainer.addObject(o));
};

View file

@ -4,8 +4,7 @@ export const defaultOptions: Options = {
port: 3000,
name: 'Test server',
playerLimit: 16,
npcCount: 16,
npcCount: 8,
seed: Math.random(),
scoreLimit: 1000,
worldSize: 8000,
scoreLimit: 2500,
};

View file

@ -41,7 +41,7 @@ export class GameServer extends CommandReceiver {
private initialize() {
const previousPlayers = this.players;
this.objects = new PhysicalContainer();
createWorld(this.objects, this.options.worldSize);
createWorld(this.objects);
this.objects.initialize();
this.players = new PlayerContainer(
this.objects,

View file

@ -32,6 +32,10 @@ export class LampPhysical extends LampBase implements StaticPhysical {
return this;
}
public queueSetLight(color: vec3, lightness: number) {
this.remoteCall('setLight', color, lightness);
}
public distance(target: vec2): number {
return vec2.distance(this.center, target);
}

View file

@ -53,6 +53,13 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
return !this.isDestroyed;
}
public get direction(): vec2 {
const direction = vec2.clone(this.velocity);
return vec2.length(direction) > 0
? vec2.normalize(direction, direction)
: vec2.fromValues(0, -1);
}
private moveOutsideOfObject() {
let wasCollision = true;
const delta = vec2.scale(

View file

@ -5,5 +5,4 @@ export interface Options {
scoreLimit: number;
npcCount: number;
seed: number;
worldSize: number;
}

View file

@ -1,9 +1,17 @@
import { vec2 } from 'gl-matrix';
import { CommandReceiver, Circle, PlayerInformation, CharacterTeam } from 'shared';
import {
CommandReceiver,
Circle,
PlayerInformation,
CharacterTeam,
Random,
settings,
} from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { isCircleIntersecting } from '../physics/functions/is-circle-intersecting';
import { CharacterPhysical } from '../objects/character-physical';
import { PlanetPhysical } from '../objects/planet-physical';
import { PlayerContainer } from './player-container';
export abstract class PlayerBase extends CommandReceiver {
@ -22,14 +30,14 @@ export abstract class PlayerBase extends CommandReceiver {
super();
}
protected createCharacter(preferredCenter: vec2) {
protected createCharacter() {
this.character = new CharacterPhysical(
this.playerInfo.name.slice(0, 20),
this.sumKills,
this.sumDeaths,
this.team,
this.objectContainer,
this.findEmptyPositionForPlayer(preferredCenter),
this.findEmptyPositionForPlayer(this.findSpawnCenter()),
);
this.objectContainer.addObject(this.character);
@ -37,11 +45,34 @@ export abstract class PlayerBase extends CommandReceiver {
public abstract step(deltaTimeInSeconds: number): void;
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
if (!preferredCenter) {
preferredCenter = vec2.create();
private findSpawnCenter(): vec2 {
const planets = this.objectContainer
.findIntersecting(
getBoundingBoxOfCircle(new Circle(vec2.create(), settings.worldRadius * 2)),
)
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
const friendly = planets.filter((p) => p.team === this.team);
const neutral = planets.filter((p) => p.team === CharacterTeam.neutral);
const candidates = friendly.length ? friendly : neutral.length ? neutral : planets;
if (candidates.length === 0) {
return vec2.create();
}
const isContested = (planet: PlanetPhysical) =>
this.playerContainer.players.some(
(p) =>
p.team !== this.team &&
p.character?.isAlive &&
vec2.distance(p.center, planet.center) < settings.spawnSafetyDistance,
);
const safe = candidates.filter((p) => !isContested(p));
// candidates is non-empty here, so choose() always returns a planet.
return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center);
}
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
let rotation = 0;
let radius = 0;
for (; ;) {

View file

@ -42,9 +42,8 @@ export class Player extends PlayerBase {
(this.aspectRatio = v.aspectRatio),
[MoveActionCommand.type]: (c: MoveActionCommand) =>
this.character?.handleMovementAction(c),
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) => {
this.character?.shootTowards(c.position);
},
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) =>
this.character?.shootTowards(c.position, c.charge),
};
constructor(
@ -60,11 +59,7 @@ export class Player extends PlayerBase {
}
protected createCharacter() {
const preferredCenter = this.playerContainer.players.find(
(p) => p.character?.isAlive && p.team === this.team,
)?.center;
super.createCharacter(preferredCenter ?? vec2.create());
super.createCharacter();
this.objectsPreviouslyInViewArea.push(this.character!);
this.queueCommandSend(new CreatePlayerCommand(this.character!));
@ -206,8 +201,4 @@ export class Player extends PlayerBase {
this.queueCommandSend(new ServerAnnouncement(announcement));
}
}
public destroy() {
super.destroy();
}
}

View file

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

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,8 +194,7 @@ 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
e.style.transform = `translateX(${p.x}px) translateY(${p.y
}px) translateX(-50%) translateY(-50%) rotate(${-angle + Math.PI / 2}rad) `;
});
@ -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'),
},
},
};
};

View file

@ -4,11 +4,14 @@ import { Command } from '../../command';
@serializable
export class PrimaryActionCommand extends Command {
public constructor(public readonly position: vec2) {
public constructor(
public readonly position: vec2,
public readonly charge: number = 0,
) {
super();
}
public toArray(): Array<any> {
return [this.position];
return [this.position, this.charge];
}
}

View file

@ -1,14 +0,0 @@
import { vec2 } from 'gl-matrix';
import { serializable } from '../../../serialization/serializable';
import { Command } from '../../command';
@serializable
export class SecondaryActionCommand extends Command {
public constructor(public readonly position: vec2) {
super();
}
public toArray(): Array<any> {
return [this.position];
}
}

View file

@ -0,0 +1,5 @@
import { clamp01 } from './clamp';
import { settings } from '../settings';
export const holdDurationToCharge = (heldSeconds: number): number =>
clamp01(heldSeconds / settings.chargeShotFullHoldSeconds);

View file

@ -16,7 +16,6 @@ export * from './commands/command-generator';
export * from './commands/types/actions/move-action';
export * from './commands/types/actions/primary-action';
export * from './commands/types/actions/set-aspect-ratio-action';
export * from './commands/types/actions/secondary-action';
export * from './helper/array';
export * from './helper/last';
export * from './helper/rgb';
@ -24,6 +23,7 @@ export * from './helper/hsl';
export * from './helper/rgb255';
export * from './helper/mix-rgb';
export * from './helper/clamp';
export * from './helper/charge';
export * from './helper/calculate-view-area';
export * from './helper/circle';
export * from './helper/rectangle';

View file

@ -28,6 +28,10 @@ export class CharacterBase extends GameObject {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public onShoot(strength: number) { }
public onHitConfirmed() { }
public onKillConfirmed(victimName?: string, streak?: number) { }
public setHealth(health: number) {
this.health = health;
}

View file

@ -13,6 +13,11 @@ export class LampBase extends GameObject {
super(id);
}
// Overridden by LampView (which lerps toward the new colour/brightness); a
// no-op on the wire base.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public setLight(color: vec3, lightness: number) {}
public toArray(): Array<any> {
const { id, center, color, lightness } = this;
return [id, center, color, lightness];

View file

@ -4,6 +4,7 @@ import { settings } from '../../settings';
import { serializable } from '../../serialization/serializable';
import { GameObject } from '../game-object';
import { Id } from '../../communication/id';
import { CharacterTeam } from './character-base';
@serializable
export class PlanetBase extends GameObject {
@ -25,6 +26,8 @@ export class PlanetBase extends GameObject {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public generatedPoints(value: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public onFlipped(team: CharacterTeam) {}
public static createPlanetVertices(
center: vec2,