This commit is contained in:
Andras Schmelczer 2026-09-14 08:04:19 +01:00
commit c074bc2645
12 changed files with 178 additions and 148 deletions

View file

@ -1,4 +1,17 @@
import { CharacterTeam } from 'shared';
export interface GameEvents {
addPoints(blue: number, red: number): void;
announce(text: string): void;
}
export const addPointsForTeam = (
game: GameEvents,
team: CharacterTeam,
points: number,
): void => {
game.addPoints(
team === CharacterTeam.blue ? points : 0,
team === CharacterTeam.red ? points : 0,
);
};

View file

@ -32,14 +32,7 @@ const io = new IoServer(server, {
const gameServer = new GameServer(io, options);
app.use(
cors({
origin: (_, callback) => {
callback(null, true);
},
credentials: true,
}),
);
app.use(cors({ origin: true, credentials: true }));
app.get(serverInformationEndpoint, (_, res) => {
res.json(gameServer.serverInfo);

View file

@ -13,6 +13,7 @@ import {
clamp01,
strengthToCharge,
stepCharacterMovement,
applyForce,
applyLeapImpulse,
decayMomentum,
tickPlanetDetachment,
@ -27,12 +28,13 @@ import {
rightFootOffset,
boundRadius,
} from 'shared';
import { addPointsForTeam } from '../game-events';
import { Physical } from '../physics/physical';
import { CirclePhysical } from './circle-physical';
import { PhysicalContainer } from '../physics/physical-container';
import { BoundingBox } from '../physics/bounding-box';
import { ProjectilePhysical } from './projectile-physical';
import { PlanetPhysical, planetsIn } from './planet-physical';
import { PlanetPhysical, planetsNear } from './planet-physical';
const placeholder = new Circle(vec2.create(), 0);
@ -99,8 +101,7 @@ export class CharacterPhysical extends CharacterBase implements Physical {
}
private readonly movementWorld: CharacterWorld = {
groundsNear: (center, radius) =>
planetsIn(this.container.findIntersecting(BoundingBox.ofCircle(center, radius))),
groundsNear: (center, radius) => planetsNear(this.container, center, radius),
stepBody: (body, deltaTimeInSeconds) => {
const hit = (body as CirclePhysical).stepManually(deltaTimeInSeconds);
return hit instanceof PlanetPhysical ? hit : undefined;
@ -139,12 +140,7 @@ export class CharacterPhysical extends CharacterBase implements Physical {
}
public get boundingBox(): BoundingBox {
const center = this.center;
this.box.xMin = center[0] - boundRadius;
this.box.xMax = center[0] + boundRadius;
this.box.yMin = center[1] - boundRadius;
this.box.yMax = center[1] + boundRadius;
return this.box;
return this.box.setCircle(this.center, boundRadius);
}
public get gameObject(): this {
@ -309,11 +305,9 @@ export class CharacterPhysical extends CharacterBase implements Physical {
this.remoteCall('onDie');
if (killedInCombat) {
const points = settings.playerKillPoint;
this.container.game.addPoints(
this.team === CharacterTeam.blue ? 0 : points,
this.team === CharacterTeam.red ? 0 : points,
);
const opposingTeam =
this.team === CharacterTeam.blue ? CharacterTeam.red : CharacterTeam.blue;
addPointsForTeam(this.container.game, opposingTeam, settings.playerKillPoint);
}
}
@ -427,7 +421,7 @@ export class CharacterPhysical extends CharacterBase implements Physical {
let grounded = false;
for (const part of this.parts) {
vec2.copy(part.velocity, this.bodyVelocity);
part.applyForce(sumGravity(planets, part.center), deltaTime);
applyForce(part, sumGravity(planets, part.center), deltaTime);
if (part.stepManually(deltaTime) instanceof PlanetPhysical) {
grounded = true;
}

View file

@ -1,5 +1,5 @@
import { vec2 } from 'gl-matrix';
import { applyForce, Circle, GameObject, resolveCircleMovement } from 'shared';
import { Circle, GameObject, resolveCircleMovement } from 'shared';
import { BoundingBox } from '../physics/bounding-box';
import { PhysicalContainer } from '../physics/physical-container';
import { Physical } from '../physics/physical';
@ -22,11 +22,7 @@ export class CirclePhysical extends Circle implements Physical {
}
public get boundingBox(): BoundingBox {
this.box.xMin = this.center[0] - this.radius;
this.box.xMax = this.center[0] + this.radius;
this.box.yMin = this.center[1] - this.radius;
this.box.yMax = this.center[1] + this.radius;
return this.box;
return this.box.setCircle(this.center, this.radius);
}
public get gameObject(): GameObject {
@ -37,10 +33,6 @@ export class CirclePhysical extends Circle implements Physical {
this.owner.onCollision?.(other);
}
public applyForce(force: vec2, timeInSeconds: number) {
applyForce(this, force, timeInSeconds);
}
public stepManually(
deltaTimeInSeconds: number,
possibleIntersectors?: Array<Physical>,

View file

@ -10,15 +10,23 @@ import {
PropertyUpdatesForObject,
UpdatePropertyCommand,
} from 'shared';
import { GameEvents } from '../game-events';
import { addPointsForTeam, GameEvents } from '../game-events';
import { BoundingBox } from '../physics/bounding-box';
import { Physical } from '../physics/physical';
import { PhysicalContainer } from '../physics/physical-container';
import { LampPhysical } from './lamp-physical';
import type { CharacterPhysical } from './character-physical';
export const planetsIn = (objects: Array<Physical>): Array<PlanetPhysical> =>
objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
export const planetsNear = (
container: PhysicalContainer,
center: vec2,
radius: number,
): Array<PlanetPhysical> =>
planetsIn(container.findIntersecting(BoundingBox.ofCircle(center, radius)));
export class PlanetPhysical extends PlanetBase implements Physical {
public readonly boundingBox: BoundingBox;
public readonly sizePointMultiplier: number;
@ -80,11 +88,7 @@ export class PlanetPhysical extends PlanetBase implements Physical {
}
private awardToOwner(base: number) {
const value = Math.round(base * this.sizePointMultiplier);
this.game.addPoints(
this.team === CharacterTeam.blue ? value : 0,
this.team === CharacterTeam.red ? value : 0,
);
addPointsForTeam(this.game, this.team, Math.round(base * this.sizePointMultiplier));
}
private resolveCapture(deltaTime: number) {

View file

@ -84,10 +84,11 @@ export class ProjectilePhysical extends ProjectileBase implements Physical {
}
public onCollision(other: GameObject) {
if (
!(other instanceof CharacterPhysical && other.team === this.team) &&
this.bounceCount++ === settings.projectileMaxBounceCount
) {
if (other instanceof CharacterPhysical && other.team === this.team) {
return;
}
this.bounceCount++;
if (this.bounceCount > settings.projectileMaxBounceCount) {
this.destroy();
}
}

View file

@ -17,6 +17,14 @@ export class BoundingBox {
);
}
public setCircle(center: vec2, radius: number): BoundingBox {
this.xMin = center[0] - radius;
this.xMax = center[0] + radius;
this.yMin = center[1] - radius;
this.yMax = center[1] + radius;
return this;
}
public intersects(other: BoundingBox): boolean {
return (
this.xMin < other.xMax &&

View file

@ -1,13 +1,10 @@
import { vec2 } from 'gl-matrix';
import { GameObject } from 'shared';
import { GameObject, Sdf } from 'shared';
import { BoundingBox } from './bounding-box';
export interface Physical {
readonly canCollide: boolean;
export interface Physical extends Sdf {
readonly boundingBox: BoundingBox;
readonly gameObject: GameObject;
distance(target: vec2): number;
step?(deltaTimeInSeconds: number): void;
onCollision?(other: GameObject): void;
}

View file

@ -1,5 +1,12 @@
import { vec2 } from 'gl-matrix';
import { PlayerInformation, settings, Random, CharacterTeam, Id } from 'shared';
import {
PlayerInformation,
settings,
Random,
CharacterTeam,
Id,
evaluateSdf,
} from 'shared';
import { PhysicalContainer } from '../physics/physical-container';
import { BoundingBox } from '../physics/bounding-box';
import { PlayerContainer } from './player-container';
@ -321,10 +328,7 @@ export class NPC extends PlayerBase {
let traveled = tuning.lineOfSightStartOffset;
const position = vec2.scaleAndAdd(vec2.create(), this.center, direction, traveled);
while (traveled < totalDistance) {
let sdf = Infinity;
for (const planet of planets) {
sdf = Math.min(sdf, planet.distance(position));
}
const sdf = evaluateSdf(position, planets);
if (sdf < tuning.lineOfSightClearance) {
return false;
}

View file

@ -12,7 +12,7 @@ import {
import { PhysicalContainer } from '../physics/physical-container';
import { BoundingBox } from '../physics/bounding-box';
import { CharacterPhysical } from '../objects/character-physical';
import { PlanetPhysical, planetsIn } from '../objects/planet-physical';
import { PlanetPhysical, planetsNear } from '../objects/planet-physical';
import { PlayerContainer } from './player-container';
const maximumNameLength = 40;
@ -65,7 +65,9 @@ export abstract class PlayerBase extends CommandReceiver {
return this.character;
}
public abstract step(deltaTimeInSeconds: number): void;
public step(deltaTimeInSeconds: number) {
this.stepLifecycle(deltaTimeInSeconds);
}
protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined {
if (this.character) {
@ -89,10 +91,10 @@ export abstract class PlayerBase extends CommandReceiver {
}
private findSpawnCenter(): vec2 {
const planets = planetsIn(
this.objectContainer.findIntersecting(
BoundingBox.ofCircle(vec2.create(), settings.worldRadius * 2),
),
const planets = planetsNear(
this.objectContainer,
vec2.create(),
settings.worldRadius * 2,
);
const friendly = planets.filter((p) => p.team === this.team);

View file

@ -36,9 +36,7 @@ export class PlayerContainer {
);
for (let i = 0; i < newNpcCount; i++) {
const name = `🤖 ${Random.choose(settings.npcNames)}`;
this.npcs.push(
new NPC({ name }, this, this.objects, this.getTeamOfNextPlayer(true)),
);
this.npcs.push(new NPC({ name }, this, this.objects, this.getTeamOfNextNpc()));
}
}
@ -102,8 +100,15 @@ export class PlayerContainer {
this._players.forEach((p) => p.sendQueuedCommandsToClient());
}
private getTeamOfNextPlayer(isNpc = false): CharacterTeam {
const players = isNpc ? this.players : this._players;
private getTeamOfNextPlayer(): CharacterTeam {
return this.teamOfNext(this._players);
}
private getTeamOfNextNpc(): CharacterTeam {
return this.teamOfNext(this.players);
}
private teamOfNext(players: Array<PlayerBase>): CharacterTeam {
const blueCount = players.filter((p) => p.team === CharacterTeam.blue).length;
const redCount = players.filter((p) => p.team === CharacterTeam.red).length;

View file

@ -42,9 +42,9 @@ const minimumAspectRatio = 0.2;
const maximumAspectRatio = 8;
const sheddableWhenBackedUp: ReadonlyArray<string> = [
PropertyUpdatesForObjects.type,
UpdateMinimap.type,
InputAcknowledgement.type,
PropertyUpdatesForObjects.name,
UpdateMinimap.name,
InputAcknowledgement.name,
];
export class Player extends PlayerBase {
@ -68,7 +68,7 @@ export class Player extends PlayerBase {
// Input is untrusted: one non-finite value reaching the simulation leaves a character that never dies.
protected commandExecutors: CommandExecutors = {
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => {
[SetAspectRatioActionCommand.name]: (v: SetAspectRatioActionCommand) => {
this.aspectRatio = finiteInRange(
v.aspectRatio,
minimumAspectRatio,
@ -76,7 +76,7 @@ export class Player extends PlayerBase {
this.aspectRatio,
);
},
[MoveActionCommand.type]: (c: MoveActionCommand) => {
[MoveActionCommand.name]: (c: MoveActionCommand) => {
const direction = finiteVec2(c.direction, 1);
if (!direction) {
return;
@ -84,7 +84,7 @@ export class Player extends PlayerBase {
this.observeClientTime(c.clientTimeMs);
this.character?.setMoveDirection(direction);
},
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) => {
[PrimaryActionCommand.name]: (c: PrimaryActionCommand) => {
const position = finiteVec2(c.position, settings.maxClientPositionMagnitude);
if (!position) {
return;
@ -92,7 +92,7 @@ export class Player extends PlayerBase {
this.observeClientTime(c.clientTimeMs);
this.character?.shootTowards(position, finiteInRange(c.charge, 0, 1, 0));
},
[LeapActionCommand.type]: (c: LeapActionCommand) => {
[LeapActionCommand.name]: (c: LeapActionCommand) => {
if (!isFiniteNumber(c.clientTimeMs)) {
return;
}
@ -100,7 +100,7 @@ export class Player extends PlayerBase {
this.observeClientTime(c.clientTimeMs);
this.character?.leap();
},
[ClientHeartbeatCommand.type]: (c: ClientHeartbeatCommand) =>
[ClientHeartbeatCommand.name]: (c: ClientHeartbeatCommand) =>
this.observeClientTime(c.clientTimeMs),
};
@ -150,10 +150,6 @@ export class Player extends PlayerBase {
this.winnerTeam = winnerTeam;
}
public step(deltaTimeInSeconds: number) {
this.stepLifecycle(deltaTimeInSeconds);
}
public queueCommandSend(command: Command) {
this.commandsToBeSent.push(command);
}
@ -273,7 +269,7 @@ export class Player extends PlayerBase {
// Only state the next snapshot regenerates may be dropped; create/delete are one-shot.
if (this.bufferedBytes > settings.maxBufferedBytesPerClient) {
this.commandsToBeSent = this.commandsToBeSent.filter(
(c) => !sheddableWhenBackedUp.includes(c.type),
(c) => !sheddableWhenBackedUp.includes(c.constructor.name),
);
}
if (this.commandsToBeSent.length === 0) {