More improvements

This commit is contained in:
Andras Schmelczer 2026-06-11 21:33:08 +01:00
parent 3848e460cd
commit e3c44f775b
11 changed files with 908 additions and 233 deletions

View file

@ -13,6 +13,7 @@ import {
Command, Command,
CommandReceiver, CommandReceiver,
CommandExecutors, CommandExecutors,
ServerAnnouncement,
} from 'shared'; } from 'shared';
import { createWorld } from './create-world'; import { createWorld } from './create-world';
import { DeltaTimeCalculator } from './helper/delta-time-calculator'; import { DeltaTimeCalculator } from './helper/delta-time-calculator';
@ -31,6 +32,7 @@ export class GameServer extends CommandReceiver {
private declaPoints = 0; private declaPoints = 0;
private redPoints = 0; private redPoints = 0;
private matchPointAnnounced: Partial<Record<CharacterTeam, boolean>> = {};
private isInEndGame = false; private isInEndGame = false;
private timeScaling = 1; private timeScaling = 1;
@ -52,6 +54,7 @@ export class GameServer extends CommandReceiver {
this.deltaTimes = []; this.deltaTimes = [];
this.declaPoints = 0; this.declaPoints = 0;
this.redPoints = 0; this.redPoints = 0;
this.matchPointAnnounced = {};
this.isInEndGame = false; this.isInEndGame = false;
this.timeScaling = 1; this.timeScaling = 1;
previousPlayers?.queueCommandForEachClient(new GameStartCommand()); previousPlayers?.queueCommandForEachClient(new GameStartCommand());
@ -126,6 +129,23 @@ export class GameServer extends CommandReceiver {
this.endGame(CharacterTeam.decla); this.endGame(CharacterTeam.decla);
} else if (this.redPoints >= this.options.scoreLimit) { } else if (this.redPoints >= this.options.scoreLimit) {
this.endGame(CharacterTeam.red); this.endGame(CharacterTeam.red);
} else {
this.announceMatchPointOnce(CharacterTeam.decla, this.declaPoints);
this.announceMatchPointOnce(CharacterTeam.red, this.redPoints);
}
}
private announceMatchPointOnce(team: CharacterTeam, points: number) {
if (
!this.matchPointAnnounced[team] &&
points >= this.options.scoreLimit * settings.matchPointScoreRatio
) {
this.matchPointAnnounced[team] = true;
this.players.queueCommandForEachClient(
new ServerAnnouncement(
`Match point — team <span class="${team}">${team}</span>!`,
),
);
} }
} }

View file

@ -12,6 +12,8 @@ import {
UpdatePropertyCommand, UpdatePropertyCommand,
CommandExecutors, CommandExecutors,
CommandReceiver, CommandReceiver,
mix,
clamp01,
} from 'shared'; } from 'shared';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
import { CirclePhysical } from './circle-physical'; import { CirclePhysical } from './circle-physical';
@ -35,8 +37,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private static readonly feetRadius = 20; private static readonly feetRadius = 20;
private projectileStrength = settings.playerMaxStrength; private projectileStrength = settings.playerMaxStrength;
// offsets are measured from (0, 0) // offsets are measured from (0, 0). The head sits this far above the feet,
private static readonly desiredHeadOffset = vec2.fromValues(0, 65); // which sets the leg length: kept short so the body reads as a compact head on
// stubby legs rather than a long-legged strider.
private static readonly desiredHeadOffset = vec2.fromValues(0, 55);
private static readonly desiredLeftFootOffset = vec2.fromValues(-20, 0); private static readonly desiredLeftFootOffset = vec2.fromValues(-20, 0);
private static readonly desiredRightFootOffset = vec2.fromValues(20, 0); private static readonly desiredRightFootOffset = vec2.fromValues(20, 0);
private static readonly centerOfMass = vec2.scale( private static readonly centerOfMass = vec2.scale(
@ -76,15 +80,21 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private isDestroyed = false; private isDestroyed = false;
private timeSinceBorn = 0; private timeSinceBorn = 0;
private hasJustBorn = true; private hasJustBorn = true;
private timeAlive = 0;
private timeSinceLastShot = settings.projectileCreationInterval;
private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds;
private lastSyncedHealth = settings.playerMaxHealth;
private killStreak = 0;
private direction = 0; private direction = 0;
private currentPlanet?: PlanetPhysical; private currentPlanet?: PlanetPhysical;
private secondsSinceOnSurface = 1000; private secondsSinceOnSurface = settings.planetDetachmentSeconds;
public head: CirclePhysical; public head: CirclePhysical;
public leftFoot: CirclePhysical; public leftFoot: CirclePhysical;
public rightFoot: CirclePhysical; public rightFoot: CirclePhysical;
public bound: CirclePhysical;
private movementActions: Array<MoveActionCommand> = []; private movementActions: Array<MoveActionCommand> = [];
private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create()); private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create());
@ -128,12 +138,12 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
container.addObject(this.head); container.addObject(this.head);
container.addObject(this.leftFoot); container.addObject(this.leftFoot);
container.addObject(this.rightFoot); container.addObject(this.rightFoot);
}
this.bound = new CirclePhysical( private get isSpawnProtected(): boolean {
vec2.create(), return (
CharacterPhysical.boundRadius, this.timeAlive <
this, settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds
container,
); );
} }
@ -156,9 +166,33 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.movementActions.push(c); this.movementActions.push(c);
} }
public addKill() { public get groundPlanet(): PlanetPhysical | undefined {
return this.currentPlanet;
}
public addKill(victimName: string) {
this.killCount++; this.killCount++;
this.killStreak++;
this.remoteCall('setKillCount', this.killCount); this.remoteCall('setKillCount', this.killCount);
this.health = Math.min(
settings.playerMaxHealth,
this.health + settings.playerKillHealthReward,
);
this.syncHealth();
this.remoteCall('onKillConfirmed', victimName, this.killStreak);
}
public registerHit() {
this.remoteCall('onHitConfirmed');
}
private syncHealth() {
const rounded = Math.round(this.health);
if (rounded !== this.lastSyncedHealth) {
this.lastSyncedHealth = rounded;
this.remoteCall('setHealth', this.health);
}
} }
public onCollision({ other }: ReactToCollisionCommand) { public onCollision({ other }: ReactToCollisionCommand) {
@ -168,28 +202,54 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
other.isAlive other.isAlive
) { ) {
other.destroy(); other.destroy();
if (this.isSpawnProtected) {
return;
}
this.timeSinceLastDamage = 0;
this.health -= other.strength; this.health -= other.strength;
this.lastSyncedHealth = Math.round(this.health);
this.remoteCall('setHealth', this.health); this.remoteCall('setHealth', this.health);
if (this.health <= 0 && this.isAlive) { if (this.health <= 0 && this.isAlive) {
this.onDie(); this.onDie();
other.originator.addKill(); other.originator.addKill(this.name);
} else {
other.originator.registerHit();
} }
} }
} }
public shootTowards(position: vec2) { public shootTowards(position: vec2, charge = 0) {
if (!this.isAlive) { if (
!this.isAlive ||
this.timeSinceLastShot < settings.projectileCreationInterval ||
this.projectileStrength < settings.chargeShotStrengthMin
) {
return; return;
} }
this.timeSinceLastShot = 0;
const c = clamp01(charge);
const desiredStrength = mix(
settings.chargeShotStrengthMin,
settings.chargeShotStrengthMax,
c,
);
const strength = Math.min(desiredStrength, this.projectileStrength);
this.projectileStrength -= strength;
const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c);
const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c);
const direction = vec2.subtract(vec2.create(), position, this.center); const direction = vec2.subtract(vec2.create(), position, this.center);
vec2.normalize(direction, direction); vec2.normalize(direction, direction);
const velocity = vec2.scale(direction, direction, settings.projectileSpeed); const velocity = vec2.scale(direction, direction, speed);
const strength = this.projectileStrength / 2;
this.projectileStrength -= strength;
const projectile = new ProjectilePhysical( const projectile = new ProjectilePhysical(
vec2.clone(this.center), vec2.clone(this.center),
20, radius,
strength, strength,
this.team, this.team,
velocity, velocity,
@ -202,8 +262,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
} }
public get boundingBox(): BoundingBoxBase { public get boundingBox(): BoundingBoxBase {
this.bound.center = this.head.center; return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius));
return this.bound.boundingBox;
} }
public get gameObject(): this { public get gameObject(): this {
@ -257,6 +316,11 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
new UpdatePropertyCommand('head', this.head, this.headVelocity), new UpdatePropertyCommand('head', this.head, this.headVelocity),
new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity), new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity),
new UpdatePropertyCommand('rightFoot', this.rightFoot, this.rightFootVelocity), new UpdatePropertyCommand('rightFoot', this.rightFoot, this.rightFootVelocity),
new UpdatePropertyCommand(
'strength',
this.projectileStrength,
settings.playerStrengthRegenerationPerSeconds,
),
]); ]);
} }
@ -298,6 +362,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private step({ deltaTimeInSeconds, game }: StepCommand) { private step({ deltaTimeInSeconds, game }: StepCommand) {
this.getPoints(game); this.getPoints(game);
this.timeAlive += deltaTimeInSeconds;
const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius); const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius);
const oldLeftFoot = new Circle( const oldLeftFoot = new Circle(
vec2.clone(this.leftFoot.center), vec2.clone(this.leftFoot.center),
@ -328,16 +393,23 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
return; return;
} }
if ((this.secondsSinceOnSurface += deltaTimeInSeconds) > 0.5) { if (
(this.secondsSinceOnSurface += deltaTimeInSeconds) >
settings.planetDetachmentSeconds
) {
this.currentPlanet = undefined; this.currentPlanet = undefined;
} }
this.timeSinceLastShot += deltaTimeInSeconds;
this.projectileStrength = Math.min( this.projectileStrength = Math.min(
settings.playerMaxStrength, settings.playerMaxStrength,
this.projectileStrength + this.projectileStrength +
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
); );
this.regenerateHealth(deltaTimeInSeconds);
this.currentPlanet?.takeControl(this.team, deltaTimeInSeconds); this.currentPlanet?.takeControl(this.team, deltaTimeInSeconds);
const intersectingWithForceField = this.container.findIntersecting( const intersectingWithForceField = this.container.findIntersecting(
@ -351,8 +423,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
const direction = this.averageAndResetMovementActions(); const direction = this.averageAndResetMovementActions();
const movementForce = vec2.scale(direction, direction, settings.maxAcceleration); const movementForce = vec2.scale(direction, direction, settings.maxAcceleration);
this.applyForce(this.leftFoot, movementForce, deltaTimeInSeconds); this.leftFoot.applyForce(movementForce, deltaTimeInSeconds);
this.applyForce(this.rightFoot, movementForce, deltaTimeInSeconds); this.rightFoot.applyForce(movementForce, deltaTimeInSeconds);
if (!this.currentPlanet) { if (!this.currentPlanet) {
const leftFootGravity = forceAtPosition( const leftFootGravity = forceAtPosition(
@ -364,8 +436,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
intersectingWithForceField, intersectingWithForceField,
); );
this.applyForce(this.leftFoot, leftFootGravity, deltaTimeInSeconds); this.leftFoot.applyForce(leftFootGravity, deltaTimeInSeconds);
this.applyForce(this.rightFoot, rightFootGravity, deltaTimeInSeconds); this.rightFoot.applyForce(rightFootGravity, deltaTimeInSeconds);
const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce); const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce);
@ -377,8 +449,11 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
vec2.add(leftFootGravity, leftFootGravity, rightFootGravity); vec2.add(leftFootGravity, leftFootGravity, rightFootGravity);
const gravity = vec2.scale(leftFootGravity, leftFootGravity, 0.5); const gravity = vec2.scale(leftFootGravity, leftFootGravity, 0.5);
if (vec2.dot(movementForce, gravity) < -vec2.length(movementForce) * 0.8) { if (
vec2.scale(gravity, gravity, 0.35); vec2.dot(movementForce, gravity) <
-vec2.length(movementForce) * settings.climbDotThreshold
) {
vec2.scale(gravity, gravity, settings.climbGravityScale);
} }
const scaledLeftFootGravity = vec2.scale( const scaledLeftFootGravity = vec2.scale(
@ -386,7 +461,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.leftFoot.lastNormal, this.leftFoot.lastNormal,
vec2.dot(this.leftFoot.lastNormal, gravity), vec2.dot(this.leftFoot.lastNormal, gravity),
); );
this.applyForce(this.leftFoot, scaledLeftFootGravity, deltaTimeInSeconds); this.leftFoot.applyForce(scaledLeftFootGravity, deltaTimeInSeconds);
const scaledRightFootGravity = vec2.scale( const scaledRightFootGravity = vec2.scale(
vec2.create(), vec2.create(),
@ -394,15 +469,15 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
vec2.dot(this.rightFoot.lastNormal, gravity), vec2.dot(this.rightFoot.lastNormal, gravity),
); );
this.applyForce(this.rightFoot, scaledRightFootGravity, deltaTimeInSeconds); this.rightFoot.applyForce(scaledRightFootGravity, deltaTimeInSeconds);
if (vec2.length(gravity) <= 100) { if (vec2.length(gravity) <= settings.planetDetachmentForceThreshold) {
this.currentPlanet = undefined; this.currentPlanet = undefined;
} }
this.setDirection(gravity); this.setDirection(gravity);
} }
this.keepPosture(deltaTimeInSeconds); this.keepPosture();
this.stepBodyPart(this.leftFoot, deltaTimeInSeconds); this.stepBodyPart(this.leftFoot, deltaTimeInSeconds);
this.stepBodyPart(this.rightFoot, deltaTimeInSeconds); this.stepBodyPart(this.rightFoot, deltaTimeInSeconds);
this.stepBodyPart(this.head, deltaTimeInSeconds); this.stepBodyPart(this.head, deltaTimeInSeconds);
@ -418,56 +493,57 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
); );
} }
private keepPosture(deltaTime: number) { private keepPosture() {
const center = this.center; const center = this.center;
this.springMove( this.springMove(
this.leftFoot, this.leftFoot,
center, center,
CharacterPhysical.leftFootOffset, CharacterPhysical.leftFootOffset,
deltaTime, settings.postureFeetStiffness,
3000,
); );
this.springMove( this.springMove(
this.rightFoot, this.rightFoot,
center, center,
CharacterPhysical.rightFootOffset, CharacterPhysical.rightFootOffset,
deltaTime, settings.postureFeetStiffness,
3000,
); );
this.springMove(this.head, center, CharacterPhysical.headOffset, deltaTime, 7000); this.springMove(
this.head,
center,
CharacterPhysical.headOffset,
settings.postureHeadStiffness,
);
} }
private springMove( private springMove(
object: CirclePhysical, object: CirclePhysical,
center: vec2, center: vec2,
offset: vec2, offset: vec2,
deltaTime: number, stiffness: number,
strength: number,
) { ) {
const desiredPosition = vec2.add(vec2.create(), center, offset); const desiredPosition = vec2.add(vec2.create(), center, offset);
vec2.rotate(desiredPosition, desiredPosition, center, this.direction); vec2.rotate(desiredPosition, desiredPosition, center, this.direction);
const positionDelta = vec2.subtract(vec2.create(), desiredPosition, object.center); const positionDelta = vec2.subtract(vec2.create(), desiredPosition, object.center);
const positionDeltaLength = vec2.length(positionDelta); // First-order velocity relaxation toward the desired posture position,
// added to (not replacing) the gravity/movement velocity accumulated
// earlier this tick. stepManually applies it over deltaTime, so the
// per-tick displacement is positionDelta * stiffness * deltaTime.
vec2.scaleAndAdd(object.velocity, object.velocity, positionDelta, stiffness);
}
if (positionDeltaLength > 0) { private regenerateHealth(deltaTimeInSeconds: number) {
const positionDeltaDirection = vec2.normalize(vec2.create(), positionDelta); this.timeSinceLastDamage += deltaTimeInSeconds;
vec2.scale( if (
positionDelta, this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds &&
positionDeltaDirection, this.health < settings.playerMaxHealth
positionDeltaLength ** 2 * deltaTime * strength, ) {
this.health = Math.min(
settings.playerMaxHealth,
this.health + settings.playerHealthRegenerationPerSeconds * deltaTimeInSeconds,
); );
this.syncHealth();
if (vec2.length(positionDelta) * deltaTime * deltaTime > positionDeltaLength) {
vec2.scale(
positionDelta,
positionDelta,
positionDeltaLength / (vec2.length(positionDelta) * deltaTime * deltaTime),
);
}
object.applyForce(positionDelta, deltaTime);
} }
} }
@ -479,14 +555,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
} }
} }
public applyForce(circle: CirclePhysical, force: vec2, timeInSeconds: number) {
vec2.add(
circle.velocity,
circle.velocity,
vec2.scale(vec2.create(), force, timeInSeconds),
);
}
public onDie() { public onDie() {
this.isDestroyed = true; this.isDestroyed = true;
this.remoteCall('onDie'); this.remoteCall('onDie');

View file

@ -4,6 +4,7 @@ import {
clamp, clamp,
clamp01, clamp01,
id, id,
mix,
serializesTo, serializesTo,
settings, settings,
PlanetBase, PlanetBase,
@ -18,20 +19,38 @@ import { StepCommand } from '../commands/step';
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
import { StaticPhysical } from '../physics/physicals/static-physical'; import { StaticPhysical } from '../physics/physicals/static-physical';
import { LampPhysical } from './lamp-physical';
@serializesTo(PlanetBase) @serializesTo(PlanetBase)
export class PlanetPhysical extends PlanetBase implements StaticPhysical { export class PlanetPhysical extends PlanetBase implements StaticPhysical {
public readonly canCollide = true; public readonly canCollide = true;
public readonly canMove = false; public readonly canMove = false;
public readonly sizePointMultiplier: number;
private _boundingBox?: ImmutableBoundingBox; private _boundingBox?: ImmutableBoundingBox;
private readonly lamps: Array<LampPhysical> = [];
private lastTeam: CharacterTeam = CharacterTeam.neutral;
protected commandExecutors: CommandExecutors = { protected commandExecutors: CommandExecutors = {
[StepCommand.type]: this.step.bind(this), [StepCommand.type]: this.step.bind(this),
}; };
public addLamp(lamp: LampPhysical) {
this.lamps.push(lamp);
}
constructor(vertices: Array<vec2>) { constructor(vertices: Array<vec2>) {
super(id(), vertices); super(id(), vertices);
const sizeClass = clamp01(
(this.radius - settings.planetMinReferenceRadius) /
(settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius),
);
this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass);
} }
public distance(target: vec2): number { public distance(target: vec2): number {
@ -80,14 +99,14 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
private getPoints(game: CommandReceiver) { private getPoints(game: CommandReceiver) {
if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) { if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) {
this.timeSinceLastPointGeneration = 0; this.timeSinceLastPointGeneration = 0;
if (this.team !== CharacterTeam.neutral) {
this.remoteCall('generatedPoints', settings.planetPointGenerationValue);
}
const value = Math.round(
settings.planetPointGenerationValue * this.sizePointMultiplier,
);
game.handleCommand( game.handleCommand(
new GeneratePointsCommand( new GeneratePointsCommand(
this.team === CharacterTeam.decla ? settings.planetPointGenerationValue : 0, this.team === CharacterTeam.decla ? value : 0,
this.team === CharacterTeam.red ? settings.planetPointGenerationValue : 0, this.team === CharacterTeam.red ? value : 0,
), ),
); );
} }
@ -95,9 +114,40 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
private step({ deltaTimeInSeconds, game }: StepCommand) { private step({ deltaTimeInSeconds, game }: StepCommand) {
this.timeSinceLastPointGeneration += deltaTimeInSeconds; this.timeSinceLastPointGeneration += deltaTimeInSeconds;
// In reverse order, so that teams can achieve a 100% control. // In reverse order, so that teams can achieve a 100% control.
this.getPoints(game); this.getPoints(game);
this.takeControl(CharacterTeam.neutral, deltaTimeInSeconds); this.takeControl(CharacterTeam.neutral, deltaTimeInSeconds);
this.detectFlip(game);
}
private detectFlip(game: CommandReceiver) {
const currentTeam = this.team;
if (currentTeam === this.lastTeam) {
return;
}
this.lastTeam = currentTeam;
if (currentTeam !== CharacterTeam.neutral) {
const reward = Math.round(
settings.captureFlipPointReward * this.sizePointMultiplier,
);
this.remoteCall('generatedPoints', reward);
game.handleCommand(
new GeneratePointsCommand(
currentTeam === CharacterTeam.decla ? reward : 0,
currentTeam === CharacterTeam.red ? reward : 0,
),
);
}
const control = Math.abs(this.ownership - 0.5) / 0.5;
const lightness = mix(settings.lampMinLightness, settings.lampMaxLightness, control);
const color = settings.palette[settings.colorIndices[currentTeam]];
this.lamps.forEach((lamp) => lamp.queueSetLight(color, lightness));
this.remoteCall('onFlipped', currentTeam);
} }
public getPropertyUpdates(): PropertyUpdatesForObject { public getPropertyUpdates(): PropertyUpdatesForObject {
@ -153,7 +203,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
public getForce(position: vec2): vec2 { public getForce(position: vec2): vec2 {
const diff = vec2.subtract(vec2.create(), this.center, position); const diff = vec2.subtract(vec2.create(), this.center, position);
const dist = Math.max(0, vec2.length(diff) - this.radius); const dist = Math.max(settings.minGravityDistance, vec2.length(diff) - this.radius);
vec2.normalize(diff, diff); vec2.normalize(diff, diff);
const scale = clamp( const scale = clamp(
settings.maxGravityQ * ((settings.maxGravityDistance / dist) ** 1.5 - 1), settings.maxGravityQ * ((settings.maxGravityDistance / dist) ** 1.5 - 1),

View file

@ -6,6 +6,7 @@ import {
Random, Random,
MoveActionCommand, MoveActionCommand,
CharacterTeam, CharacterTeam,
Id,
} from 'shared'; } from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-container'; import { PhysicalContainer } from '../physics/containers/physical-container';
import { PlayerContainer } from './player-container'; import { PlayerContainer } from './player-container';
@ -13,16 +14,77 @@ import { PlayerBase } from './player-base';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { CharacterPhysical } from '../objects/character-physical'; import { CharacterPhysical } from '../objects/character-physical';
import { PlanetPhysical } from '../objects/planet-physical'; import { PlanetPhysical } from '../objects/planet-physical';
import { ProjectilePhysical } from '../objects/projectile-physical';
import { Physical } from '../physics/physicals/physical'; import { Physical } from '../physics/physicals/physical';
const npcTuning = {
planIntervalSeconds: 1,
shootIntervalSeconds: 1.5,
reactionIntervalSeconds: 1 / 10,
reactionObserveRadius: 1400,
planScanRadius: 3500,
aggressionMin: 0.25,
aggressionMax: 1,
wanderReconsiderSeconds: 5,
wanderProbability: 0.4,
wanderTurn: 0.3,
fleeBaseRange: 240,
fleeAggressionFalloff: 1.4,
chaseBaseRange: 700,
chaseAggressionRange: 1600,
captureHoldEnemyDistance: 500,
dodgeThreatRange: 450,
dodgeApproachDot: 0.6,
dodgeBaseChance: 0.25,
dodgeAggressionChance: 0.35,
dodgeCommitSeconds: 0.35,
dodgeCooldownSeconds: 0.5,
lineOfSightClearance: 20,
lineOfSightStartOffset: 100,
fireBaseChance: 0.45,
fireAggressionChance: 0.45,
chargeRangeThreshold: 500,
chargeBaseChance: 0.3,
chargeAggressionChance: 0.4,
chargeMin: 0.6,
spreadBase: 60,
spreadPerDistance: 0.08,
spreadAggressionFalloff: 1.3,
};
export class NPC extends PlayerBase { export class NPC extends PlayerBase {
private direction: vec2 = vec2.fromValues(Random.getRandom(), Random.getRandom()); private direction = vec2.fromValues(Random.getRandom() - 0.5, Random.getRandom() - 0.5);
private timeSinceLastFindTarget = 10000; private timeSinceLastPlan = 10000;
private timeSinceLastFindShootTarget = 10000; private timeSinceLastShoot = 10000;
private isWandering = false; private isWandering = false;
private timeSinceLastWanderingConsideration = 0; private timeSinceLastWanderingConsideration = 0;
private isComingBack = false; private isComingBack = false;
private readonly aggression = Random.getRandomInRange(
npcTuning.aggressionMin,
npcTuning.aggressionMax,
);
private aimTargetId: Id = null;
private readonly aimTargetLastPosition = vec2.create();
private readonly dodgeDirection = vec2.create();
private dodgeCommitRemaining = 0;
private dodgeCooldownRemaining = 0;
private timeSinceObserve = npcTuning.reactionIntervalSeconds;
private nearObjects: Array<Physical> = [];
constructor( constructor(
playerInfo: PlayerInformation, playerInfo: PlayerInformation,
playerContainer: PlayerContainer, playerContainer: PlayerContainer,
@ -34,172 +96,304 @@ export class NPC extends PlayerBase {
this.step(0); this.step(0);
} }
private findTarget() {
if (
(!this.isComingBack && vec2.length(this.center) > settings.worldRadius) ||
(this.isComingBack && vec2.length(this.center) > settings.worldRadius / 2)
) {
this.isComingBack = true;
vec2.subtract(this.direction, vec2.fromValues(0, 0), this.center);
return;
}
this.isComingBack = false;
const observableArea = getBoundingBoxOfCircle(new Circle(this.center, 2000));
const nearObjects = this.objectContainer.findIntersecting(observableArea);
const characters = this.findNearCharactersSorted(nearObjects);
if (characters.length > 0) {
const nearest = characters[0];
if (nearest.distance < 200) {
vec2.subtract(this.direction, this.center, nearest.character.center);
return;
}
}
const enemies = characters.filter((o) => o.character.team !== this.team);
if (enemies.length > 0) {
const nearest = enemies[0];
if (nearest.distance < 500) {
vec2.subtract(this.direction, this.center, nearest.character.center);
return;
}
}
if (enemies.length > 0) {
const nearest = enemies[0];
if (nearest.distance > 1000) {
vec2.subtract(this.direction, nearest.character.center, this.center);
return;
}
}
if (this.isWandering) {
vec2.rotate(
this.direction,
this.direction,
vec2.create(),
Random.getRandomInRange(-0.2, 0.2),
);
} else {
const planets = this.findNearPlanetsSorted(nearObjects).filter(
(p) => p.planet.team !== this.team,
);
if (planets.length > 0) {
vec2.subtract(this.direction, planets[0].planet.center, this.center);
} else {
this.isWandering = true;
}
}
}
private findNearCharactersSorted(
nearObjects: Array<Physical>,
): Array<{ character: CharacterPhysical; distance: number }> {
const characters = Array.from(
new Set(
nearObjects.filter(
(o) =>
o.gameObject instanceof CharacterPhysical && o.gameObject !== this.character,
),
),
).map((c) => ({
character: c.gameObject,
distance: vec2.distance(this.center, (c.gameObject as CharacterPhysical).center),
})) as Array<{ character: CharacterPhysical; distance: number }>;
characters.sort((a, b) => a.distance - b.distance);
return characters;
}
private findNearPlanetsSorted(
nearObjects: Array<Physical>,
): Array<{ planet: PlanetPhysical; distance: number }> {
const planets = nearObjects
.filter((o) => o.gameObject instanceof PlanetPhysical)
.map((c) => ({
planet: c.gameObject,
distance: vec2.distance(this.center, (c.gameObject as PlanetPhysical).center),
})) as Array<{ planet: PlanetPhysical; distance: number }>;
planets.sort((a, b) => a.distance - b.distance);
return planets;
}
private findShootTarget(): vec2 | undefined {
const observableArea = getBoundingBoxOfCircle(new Circle(this.center, 1000));
const nearObjects = this.objectContainer.findIntersecting(observableArea);
const enemies = nearObjects.filter(
(o) => o.gameObject instanceof CharacterPhysical && o.gameObject.team !== this.team,
);
if (enemies.length > 0) {
return (enemies[0].gameObject as CharacterPhysical).center;
}
}
private timeUntilRespawn = 0; private timeUntilRespawn = 0;
public step(deltaTimeInSeconds: number) { public step(deltaTimeInSeconds: number) {
if (this.character) { if (this.character) {
this.center = this.character?.center; this.center = this.character.center;
if (!this.character.isAlive) { if (!this.character.isAlive) {
this.sumDeaths++; this.sumDeaths++;
this.sumKills = this.character.killCount; this.sumKills = this.character.killCount;
this.character = null; this.character = null;
this.timeUntilRespawn = settings.playerDiedTimeout; this.timeUntilRespawn = settings.playerDiedTimeout;
return;
} }
} else { } else {
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
this.createCharacter(); this.createCharacter();
this.center = this.character!.center; this.center = this.character!.center;
} }
return;
} }
if ((this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) > 3) { if (
(this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) >
npcTuning.wanderReconsiderSeconds
) {
this.timeSinceLastWanderingConsideration = 0; this.timeSinceLastWanderingConsideration = 0;
this.isWandering = Random.getRandom() > 0.5; this.isWandering = Random.getRandom() < npcTuning.wanderProbability;
} }
if ((this.timeSinceLastFindTarget += deltaTimeInSeconds) > 1) { if ((this.timeSinceLastPlan += deltaTimeInSeconds) > npcTuning.planIntervalSeconds) {
this.timeSinceLastFindTarget = 0; this.timeSinceLastPlan = 0;
this.findTarget(); this.plan();
} }
if ((this.timeSinceLastFindShootTarget += deltaTimeInSeconds) > 0.5) { if (
if (Random.getRandom() > 0.5) { (this.timeSinceObserve += deltaTimeInSeconds) > npcTuning.reactionIntervalSeconds
const shootTarget = this.findShootTarget(); ) {
if (shootTarget) { this.timeSinceObserve = 0;
vec2.add( this.nearObjects = this.observe(npcTuning.reactionObserveRadius);
shootTarget,
shootTarget,
vec2.fromValues(
Random.getRandomInRange(-200, 200),
Random.getRandomInRange(-200, 200),
),
);
this.character?.shootTowards(shootTarget);
}
}
this.timeSinceLastFindShootTarget = 0;
} }
this.character?.handleMovementAction(new MoveActionCommand(this.direction)); this.dodgeCommitRemaining -= deltaTimeInSeconds;
this.dodgeCooldownRemaining -= deltaTimeInSeconds;
const movement = this.decideMovement(this.nearObjects);
this.character.handleMovementAction(new MoveActionCommand(movement));
if (
(this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds
) {
this.timeSinceLastShoot = 0;
this.tryShoot(this.nearObjects);
}
} }
protected createCharacter() { private plan() {
const randomPoint = vec2.rotate( const distanceFromCentre = vec2.length(this.center);
if (
(!this.isComingBack && distanceFromCentre > settings.worldRadius) ||
(this.isComingBack && distanceFromCentre > settings.worldRadius / 2)
) {
this.isComingBack = true;
vec2.negate(this.direction, this.center);
return;
}
this.isComingBack = false;
const nearObjects = this.observe(npcTuning.planScanRadius);
const enemies = this.enemiesByDistance(nearObjects);
if (enemies.length > 0) {
const nearest = enemies[0];
const fleeRange =
npcTuning.fleeBaseRange * (npcTuning.fleeAggressionFalloff - this.aggression);
if (nearest.distance < fleeRange) {
vec2.subtract(this.direction, this.center, nearest.character.center);
return;
}
}
if (enemies.length > 0) {
const nearest = enemies[0];
const chaseRange =
npcTuning.chaseBaseRange + npcTuning.chaseAggressionRange * this.aggression;
if (nearest.distance < chaseRange) {
vec2.subtract(this.direction, nearest.character.center, this.center);
return;
}
}
if (!this.isWandering) {
const planet = this.capturablePlanetsByDistance(nearObjects)[0];
if (planet) {
vec2.subtract(this.direction, planet.planet.center, this.center);
return;
}
}
vec2.rotate(
this.direction,
this.direction,
vec2.create(), vec2.create(),
vec2.fromValues(Random.getRandomInRange(0, settings.worldRadius), 0), Random.getRandomInRange(-npcTuning.wanderTurn, npcTuning.wanderTurn),
vec2.create(),
Random.getRandomInRange(0, Math.PI * 2),
); );
super.createCharacter(randomPoint); }
private decideMovement(nearObjects: Array<Physical>): vec2 {
if (this.dodgeCommitRemaining > 0) {
return vec2.clone(this.dodgeDirection);
}
if (this.dodgeCooldownRemaining <= 0) {
const dodge = this.dodgeVector(nearObjects);
if (dodge) {
if (
Random.getRandom() <
npcTuning.dodgeBaseChance + npcTuning.dodgeAggressionChance * this.aggression
) {
vec2.copy(this.dodgeDirection, dodge);
this.dodgeCommitRemaining = npcTuning.dodgeCommitSeconds;
return vec2.clone(this.dodgeDirection);
}
this.dodgeCooldownRemaining = npcTuning.dodgeCooldownSeconds;
}
}
const planet = this.character!.groundPlanet;
if (planet && planet.team !== this.team) {
const enemies = this.enemiesByDistance(nearObjects);
if (
enemies.length === 0 ||
enemies[0].distance > npcTuning.captureHoldEnemyDistance
) {
return vec2.create();
}
}
return vec2.normalize(vec2.create(), this.direction);
}
private dodgeVector(nearObjects: Array<Physical>): vec2 | undefined {
let threat: ProjectilePhysical | undefined;
let threatDistance = Infinity;
for (const o of nearObjects) {
const p = o.gameObject;
if (!(p instanceof ProjectilePhysical) || p.team === this.team || !p.isAlive) {
continue;
}
const toMe = vec2.subtract(vec2.create(), this.center, p.center);
const distance = vec2.length(toMe);
if (distance > npcTuning.dodgeThreatRange || distance === 0) {
continue;
}
vec2.normalize(toMe, toMe);
if (
vec2.dot(p.direction, toMe) > npcTuning.dodgeApproachDot &&
distance < threatDistance
) {
threatDistance = distance;
threat = p;
}
}
if (!threat) {
return undefined;
}
const perpendicular = vec2.fromValues(-threat.direction.y, threat.direction.x);
const toMe = vec2.subtract(vec2.create(), this.center, threat.center);
if (vec2.dot(perpendicular, toMe) < 0) {
vec2.negate(perpendicular, perpendicular);
}
vec2.normalize(perpendicular, perpendicular);
return perpendicular;
}
private tryShoot(nearObjects: Array<Physical>) {
const enemies = this.enemiesByDistance(nearObjects);
const visible = enemies.find((e) =>
this.hasLineOfSightTo(e.character.center, nearObjects),
);
if (!visible) {
this.aimTargetId = null;
return;
}
const target = visible.character;
const distance = visible.distance;
const velocity = vec2.create();
if (this.aimTargetId === target.id) {
vec2.subtract(velocity, target.center, this.aimTargetLastPosition);
vec2.scale(velocity, velocity, 1 / npcTuning.shootIntervalSeconds);
}
this.aimTargetId = target.id;
vec2.copy(this.aimTargetLastPosition, target.center);
if (
Random.getRandom() >
npcTuning.fireBaseChance + npcTuning.fireAggressionChance * this.aggression
) {
return;
}
const charge =
distance > npcTuning.chargeRangeThreshold &&
Random.getRandom() <
npcTuning.chargeBaseChance + npcTuning.chargeAggressionChance * this.aggression
? Random.getRandomInRange(npcTuning.chargeMin, 1)
: 0;
const projectileSpeed =
charge > 0 ? settings.chargeShotSpeedMax : settings.chargeShotSpeedMin;
const leadTime = distance / projectileSpeed;
const aim = vec2.scaleAndAdd(vec2.create(), target.center, velocity, leadTime);
(npcTuning.spreadBase + distance * npcTuning.spreadPerDistance) *
(npcTuning.spreadAggressionFalloff - this.aggression);
aim.x += Random.getRandomInRange(-spread, spread);
aim.y += Random.getRandomInRange(-spread, spread);
this.character!.shootTowards(aim, charge);
}
private hasLineOfSightTo(target: vec2, nearObjects: Array<Physical>): boolean {
const planets: Array<PlanetPhysical> = [];
for (const o of nearObjects) {
if (o.gameObject instanceof PlanetPhysical) {
planets.push(o.gameObject);
}
}
if (planets.length === 0) {
return true;
}
const direction = vec2.subtract(vec2.create(), target, this.center);
const totalDistance = vec2.length(direction);
if (totalDistance <= npcTuning.lineOfSightStartOffset) {
return true;
}
vec2.normalize(direction, direction);
let traveled = npcTuning.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));
}
if (sdf < npcTuning.lineOfSightClearance) {
return false;
}
traveled += sdf;
vec2.scaleAndAdd(position, position, direction, sdf);
}
return true;
}
private observe(radius: number): Array<Physical> {
return this.objectContainer.findIntersecting(
getBoundingBoxOfCircle(new Circle(this.center, radius)),
);
}
private enemiesByDistance(
nearObjects: Array<Physical>,
): Array<{ character: CharacterPhysical; distance: number }> {
const seen = new Set<CharacterPhysical>();
const enemies: Array<{ character: CharacterPhysical; distance: number }> = [];
for (const o of nearObjects) {
const c = o.gameObject;
if (
c instanceof CharacterPhysical &&
c !== this.character &&
c.isAlive &&
c.team !== this.team &&
!seen.has(c)
) {
seen.add(c);
enemies.push({ character: c, distance: vec2.distance(this.center, c.center) });
}
}
enemies.sort((a, b) => a.distance - b.distance);
return enemies;
}
private capturablePlanetsByDistance(
nearObjects: Array<Physical>,
): Array<{ planet: PlanetPhysical; distance: number }> {
const planets = nearObjects
.filter(
(o): o is Physical =>
o.gameObject instanceof PlanetPhysical && o.gameObject.team !== this.team,
)
.map((o) => ({
planet: o.gameObject as PlanetPhysical,
distance: vec2.distance(this.center, (o.gameObject as PlanetPhysical).center),
}));
planets.sort((a, b) => a.distance - b.distance);
return planets;
} }
} }

View file

@ -1,8 +1,14 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { CommandGenerator, PrimaryActionCommand, SecondaryActionCommand } from 'shared'; import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared';
import { Game } from '../game'; import { Game } from '../game';
import { ChargeIndicator } from '../charge-indicator';
import { Pointer } from '../helper/pointer';
export class MouseListener extends CommandGenerator { 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.
private primaryDownAt: number | null = null;
constructor( constructor(
private target: HTMLElement, private target: HTMLElement,
private readonly game: Game, private readonly game: Game,
@ -10,22 +16,43 @@ export class MouseListener extends CommandGenerator {
super(); super();
target.addEventListener('mousedown', this.mouseDownListener); target.addEventListener('mousedown', this.mouseDownListener);
target.addEventListener('mouseup', this.mouseUpListener);
target.addEventListener('mousemove', this.mouseMoveListener);
target.addEventListener('contextmenu', this.contextMenuListener); target.addEventListener('contextmenu', this.contextMenuListener);
} }
private mouseDownListener = (event: MouseEvent) => { // Only the screen position is stored; it is reprojected to world space each
const position = this.positionFromEvent(event); // 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);
};
private mouseDownListener = (event: MouseEvent) => {
if (event.button === 0) { if (event.button === 0) {
this.sendCommandToSubscribers(new PrimaryActionCommand(position)); 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);
} }
}; };
private mouseUpListener = (event: MouseEvent) => {
if (event.button !== 0 || this.primaryDownAt === null) {
return;
}
ChargeIndicator.end();
const charge = holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
this.primaryDownAt = null;
this.sendCommandToSubscribers(
new PrimaryActionCommand(this.positionFromEvent(event), charge),
);
};
// Suppress the browser context menu on the canvas; right-click has no action.
private contextMenuListener = (event: MouseEvent) => { private contextMenuListener = (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
const position = this.positionFromEvent(event);
this.sendCommandToSubscribers(new SecondaryActionCommand(position));
}; };
private positionFromEvent(event: MouseEvent): vec2 { private positionFromEvent(event: MouseEvent): vec2 {
@ -35,7 +62,10 @@ export class MouseListener extends CommandGenerator {
} }
public destroy() { public destroy() {
ChargeIndicator.end();
this.target.removeEventListener('mousedown', this.mouseDownListener); this.target.removeEventListener('mousedown', this.mouseDownListener);
this.target.removeEventListener('mouseup', this.mouseUpListener);
this.target.removeEventListener('mousemove', this.mouseMoveListener);
this.target.removeEventListener('contextmenu', this.contextMenuListener); this.target.removeEventListener('contextmenu', this.contextMenuListener);
} }
} }

View file

@ -1,6 +1,14 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { CommandGenerator, MoveActionCommand, last, PrimaryActionCommand } from 'shared'; import {
CommandGenerator,
MoveActionCommand,
last,
PrimaryActionCommand,
holdDurationToCharge,
settings,
} from 'shared';
import { Game } from '../game'; import { Game } from '../game';
import { ChargeIndicator } from '../charge-indicator';
export class TouchListener extends CommandGenerator { export class TouchListener extends CommandGenerator {
private static readonly deadZone = 8; private static readonly deadZone = 8;
@ -10,6 +18,12 @@ export class TouchListener extends CommandGenerator {
private joystickButton: HTMLElement; private joystickButton: HTMLElement;
private isJoystickActive = false; private isJoystickActive = false;
private touchStartPosition!: vec2; private touchStartPosition!: vec2;
private primaryDownAt: number | null = null;
private fireButton: HTMLElement;
private fireStrengthRing: HTMLElement;
private fireDownAt: number | null = null;
constructor( constructor(
private target: HTMLElement, private target: HTMLElement,
@ -23,6 +37,17 @@ export class TouchListener extends CommandGenerator {
this.joystickButton = document.createElement('div'); this.joystickButton = document.createElement('div');
this.joystick.appendChild(this.joystickButton); 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.fireButton.addEventListener('touchstart', this.fireButtonDownListener);
this.fireButton.addEventListener('touchend', this.fireButtonUpListener);
this.overlay.appendChild(this.fireButton);
target.addEventListener('touchstart', this.touchStartListener); target.addEventListener('touchstart', this.touchStartListener);
target.addEventListener('touchmove', this.touchMoveListener); target.addEventListener('touchmove', this.touchMoveListener);
target.addEventListener('touchend', this.touchEndListener); target.addEventListener('touchend', this.touchEndListener);
@ -43,6 +68,8 @@ export class TouchListener extends CommandGenerator {
event.touches[0].clientX, event.touches[0].clientX,
event.touches[0].clientY, event.touches[0].clientY,
); );
this.primaryDownAt = performance.now();
ChargeIndicator.begin(this.touchStartPosition.x, this.touchStartPosition.y);
} }
}; };
@ -60,6 +87,8 @@ export class TouchListener extends CommandGenerator {
if (!this.isJoystickActive && deltaLength > TouchListener.deadZone) { if (!this.isJoystickActive && deltaLength > TouchListener.deadZone) {
this.isJoystickActive = true; this.isJoystickActive = true;
this.primaryDownAt = null;
ChargeIndicator.end();
this.overlay.appendChild(this.joystick); this.overlay.appendChild(this.joystick);
this.joystickButton.style.transform = `translateX(-50%) translateY(-50%)`; 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 = `translateX(${this.touchStartPosition.x}px) translateY(${this.touchStartPosition.y}px) translateX(-50%) translateY(-50%)`;
@ -71,7 +100,8 @@ export class TouchListener extends CommandGenerator {
vec2.set(delta, delta.x, -delta.y); vec2.set(delta, delta.x, -delta.y);
if (deltaLength > TouchListener.deadZone) { if (deltaLength > TouchListener.deadZone) {
this.sendCommandToSubscribers(new MoveActionCommand(vec2.normalize(delta, delta))); const direction = vec2.normalize(delta, delta);
this.sendCommandToSubscribers(new MoveActionCommand(direction));
} else { } else {
this.sendCommandToSubscribers(new MoveActionCommand(vec2.create())); this.sendCommandToSubscribers(new MoveActionCommand(vec2.create()));
} }
@ -81,12 +111,18 @@ export class TouchListener extends CommandGenerator {
event.preventDefault(); event.preventDefault();
if (!this.isJoystickActive) { if (!this.isJoystickActive) {
ChargeIndicator.end();
const charge =
this.primaryDownAt === null
? 0
: holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
this.primaryDownAt = null;
const center = vec2.fromValues( const center = vec2.fromValues(
event.changedTouches[0].clientX, event.changedTouches[0].clientX,
event.changedTouches[0].clientY, event.changedTouches[0].clientY,
); );
this.sendCommandToSubscribers( this.sendCommandToSubscribers(
new PrimaryActionCommand(this.game.displayToWorldCoordinates(center)), new PrimaryActionCommand(this.game.displayToWorldCoordinates(center), charge),
); );
} else if (event.touches.length === 0) { } else if (event.touches.length === 0) {
this.isJoystickActive = false; this.isJoystickActive = false;
@ -95,9 +131,62 @@ export class TouchListener extends CommandGenerator {
} }
}; };
private swallowTouch = (event: TouchEvent) => {
event.preventDefault();
event.stopPropagation();
};
private fireButtonDownListener = (event: TouchEvent) => {
this.swallowTouch(event);
this.fireDownAt = performance.now();
const rect = this.fireButton.getBoundingClientRect();
ChargeIndicator.begin(rect.left + rect.width / 2, rect.top + rect.height / 2);
};
private fireButtonUpListener = (event: TouchEvent) => {
this.swallowTouch(event);
ChargeIndicator.end();
if (this.fireDownAt === null) {
return;
}
const charge = holdDurationToCharge((performance.now() - this.fireDownAt) / 1000);
this.fireDownAt = null;
const character = this.game.gameObjects.player;
if (!character) {
return;
}
const aim = vec2.scaleAndAdd(
vec2.create(),
character.bodyCenter,
character.facingDirection,
settings.touchAimRange,
);
this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge));
};
public update(_deltaTimeInSeconds: number) {
if (!this.fireButton.parentElement) {
this.overlay.appendChild(this.fireButton);
}
const character = this.game.gameObjects.player;
if (character) {
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${character.strengthFraction * 360
}deg, transparent 0deg)`;
}
}
public destroy() { public destroy() {
ChargeIndicator.end();
this.target.removeEventListener('touchstart', this.touchStartListener); this.target.removeEventListener('touchstart', this.touchStartListener);
this.target.removeEventListener('touchmove', this.touchMoveListener); this.target.removeEventListener('touchmove', this.touchMoveListener);
this.target.removeEventListener('touchend', this.touchEndListener); this.target.removeEventListener('touchend', this.touchEndListener);
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
this.fireButton.removeEventListener('touchend', this.fireButtonUpListener);
this.fireButton.parentElement?.removeChild(this.fireButton);
} }
} }

View file

@ -1,11 +1,6 @@
import { settings } from 'shared'; import { settings } from 'shared';
import { Pointer } from './helper/pointer'; import { Pointer } from './helper/pointer';
// Lightweight, self-managing combat-feedback overlay for the local player:
// hitmarkers, a personal killfeed, multi-kill callouts and a points popup. It
// owns a single fixed, click-through root under <body> and auto-expires every
// element it creates, so callers (CharacterView remote-call handlers) just fire
// and forget — no wiring through the game loop.
export abstract class FeedbackHud { export abstract class FeedbackHud {
private static root?: HTMLElement; private static root?: HTMLElement;
private static killfeed?: HTMLElement; private static killfeed?: HTMLElement;
@ -24,8 +19,6 @@ export abstract class FeedbackHud {
return { root: this.root, killfeed: this.killfeed }; return { root: this.root, killfeed: this.killfeed };
} }
// Where transient marks/popups appear: the cursor on desktop, screen centre on
// touch (where the local character fires along its facing).
private static focusPoint(): { x: number; y: number } { private static focusPoint(): { x: number; y: number } {
const cursor = Pointer.getDisplayPosition(); const cursor = Pointer.getDisplayPosition();
if (cursor) { if (cursor) {
@ -40,7 +33,6 @@ export abstract class FeedbackHud {
setTimeout(() => element.parentElement?.removeChild(element), lifetimeMs); setTimeout(() => element.parentElement?.removeChild(element), lifetimeMs);
} }
// A non-lethal hit landed: a quick crosshair tick at the focus point.
public static hitMarker() { public static hitMarker() {
const { x, y } = this.focusPoint(); const { x, y } = this.focusPoint();
const marker = document.createElement('div'); const marker = document.createElement('div');
@ -50,7 +42,6 @@ export abstract class FeedbackHud {
this.addTransient(marker, 250); this.addTransient(marker, 250);
} }
// A kill was confirmed: killfeed line, points popup and (for streaks) a callout.
public static killConfirmed(victimName?: string, streak = 1) { public static killConfirmed(victimName?: string, streak = 1) {
const { killfeed } = this.ensureRoot(); const { killfeed } = this.ensureRoot();

View file

@ -15,6 +15,7 @@ import { StepCommand } from '../commands/types/step';
import { Game } from '../game'; import { Game } from '../game';
import { Camera } from './types/camera'; import { Camera } from './types/camera';
import { CharacterView } from './types/character-view'; import { CharacterView } from './types/character-view';
import { PlanetView } from './types/planet-view';
export class GameObjectContainer extends CommandReceiver { export class GameObjectContainer extends CommandReceiver {
protected objects: Map<Id, GameObject> = new Map(); protected objects: Map<Id, GameObject> = new Map();
@ -57,6 +58,16 @@ export class GameObjectContainer extends CommandReceiver {
super(); super();
} }
public get planets(): Array<PlanetView> {
const planets: Array<PlanetView> = [];
this.objects.forEach((o) => {
if (o instanceof PlanetView) {
planets.push(o);
}
});
return planets;
}
protected defaultCommandExecutor(c: Command) { protected defaultCommandExecutor(c: Command) {
this.objects.forEach((o) => o.handleCommand(c)); this.objects.forEach((o) => o.handleCommand(c));
this.camera.handleCommand(c); this.camera.handleCommand(c);

View file

@ -49,6 +49,11 @@ export class Scoreboard {
this.declaFill.style.width = fraction(declaCount) + '%'; this.declaFill.style.width = fraction(declaCount) + '%';
this.redFill.style.width = fraction(redCount) + '%'; this.redFill.style.width = fraction(redCount) + '%';
const isMatchPoint = (count: number) =>
count / limit >= settings.matchPointScoreRatio;
this.declaFill.classList.toggle('match-point', isMatchPoint(declaCount));
this.redFill.classList.toggle('match-point', isMatchPoint(redCount));
this.declaScore.innerText = String(Math.round(declaCount)); this.declaScore.innerText = String(Math.round(declaCount));
this.redScore.innerText = String(Math.round(redCount)); this.redScore.innerText = String(Math.round(redCount));

View file

@ -0,0 +1,219 @@
import { mat2d, vec2 } from 'gl-matrix';
import { Drawable, DrawableDescriptor } from 'sdf-2d';
import { Circle } from 'shared';
// A single character silhouette shared by every team (teams differ only by
// colour): a circular head sitting on two thin line-legs that run down to the
// two feet.
export class CharacterShape extends Drawable {
public static descriptor: DrawableDescriptor = {
sdf: {
shader: `
uniform vec2 characterHeadCenters[CHARACTER_COUNT];
uniform vec2 characterLeftFeet[CHARACTER_COUNT];
uniform vec2 characterRightFeet[CHARACTER_COUNT];
uniform vec2 characterGazeTargets[CHARACTER_COUNT];
uniform float characterHeadRadii[CHARACTER_COUNT];
uniform float characterFootRadii[CHARACTER_COUNT];
uniform int characterColors[CHARACTER_COUNT];
uniform float characterFlash[CHARACTER_COUNT];
float circleDistance(vec2 circleCenter, float radius, vec2 target) {
return distance(target, circleCenter) - radius;
}
// Distance to the segment a->b; subtract a radius to get a capsule (a
// rounded thick line).
float segmentDistance(vec2 a, vec2 b, vec2 target) {
vec2 pa = target - a;
vec2 ba = b - a;
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
return length(pa - ba * h);
}
float characterMinDistance(vec2 target, out vec4 color) {
float minDistance = 1000.0;
for (int i = 0; i < CHARACTER_COUNT; i++) {
vec2 head = characterHeadCenters[i];
vec2 leftFoot = characterLeftFeet[i];
vec2 rightFoot = characterRightFeet[i];
float headRadius = characterHeadRadii[i];
float footRadius = characterFootRadii[i];
// The server rotates the whole posture toward travel, so the head
// leads: forward is the facing direction, perp its 90deg rotation, so
// the face sits on the leading side and reads at any rotation.
vec2 footAverage = (leftFoot + rightFoot) * 0.5;
vec2 toHead = head - footAverage;
float toHeadLength = length(toHead);
vec2 forward = toHeadLength > 0.001 ? toHead / toHeadLength : vec2(0.0, 1.0);
vec2 perp = vec2(-forward.y, forward.x);
// A circular head, drawn a bit smaller than the head->feet gap so the
// legs below it read as two distinct LINES rather than being swallowed
// by the head.
float renderRadius = headRadius * 0.75;
float headDistance = circleDistance(head, renderRadius, target);
// Legs as thin line-segments (capsules) from the head down to each
// foot, hard-min'd onto the head so the head stays a crisp circle.
float legThickness = footRadius * 0.35;
float leftLeg = segmentDistance(head, leftFoot, target) - legThickness;
float rightLeg = segmentDistance(head, rightFoot, target) - legThickness;
// Rounded feet at the ends of the lines, kept large enough to stay
// visible even when the leg is short and the head nearly reaches them.
float footRender = footRadius * 0.7;
float leftFootDistance = circleDistance(leftFoot, footRender, target);
float rightFootDistance = circleDistance(rightFoot, footRender, target);
float body = min(
headDistance,
min(min(leftLeg, rightLeg), min(leftFootDistance, rightFootDistance))
);
// Real eyes painted on the leading face — a bright white sclera with a
// dark pupil — rather than carved holes, so the character reads as
// alive instead of hollow-socketed.
// Big white sclera, small pupil: the eye must read as mostly white so
// it looks like an eye, not a dark socket. (An 8-bit albedo caps the
// sclera at pure white, so its area — not its colour — is what makes
// the eye read brighter against the body.)
vec2 eyeBase = head + forward * (renderRadius * 0.26);
vec2 leftEyeCenter = eyeBase + perp * (renderRadius * 0.38);
vec2 rightEyeCenter = eyeBase - perp * (renderRadius * 0.38);
float scleraRadius = renderRadius * 0.26;
float pupilRadius = renderRadius * 0.11;
// The pupil slides toward the gaze target (the cursor for the local
// player, otherwise the travel direction), kept well inside the rim so
// a generous ring of white always frames it. The normalize is guarded
// so a cursor resting exactly on an eye can't produce NaNs.
vec2 gazeTarget = characterGazeTargets[i];
float pupilReach = (scleraRadius - pupilRadius) * 0.6;
vec2 toLeftGaze = gazeTarget - leftEyeCenter;
vec2 toRightGaze = gazeTarget - rightEyeCenter;
vec2 leftGaze = length(toLeftGaze) > 0.001 ? normalize(toLeftGaze) : forward;
vec2 rightGaze = length(toRightGaze) > 0.001 ? normalize(toRightGaze) : forward;
float sclera = min(
circleDistance(leftEyeCenter, scleraRadius, target),
circleDistance(rightEyeCenter, scleraRadius, target)
);
float pupil = min(
circleDistance(leftEyeCenter + leftGaze * pupilReach, pupilRadius, target),
circleDistance(rightEyeCenter + rightGaze * pupilReach, pupilRadius, target)
);
// Soften each eye edge by at least one screen texel so the colour
// boundary antialiases instead of staircasing when the character is
// small on screen. The fixed world-space term sets a floor on the
// softness when zoomed in; fwidth widens the band to a texel as the
// head shrinks. Computed here in uniform control flow — NOT inside the
// body branch below — so the screen-space derivatives stay defined.
// fwidth needs WebGL2 derivatives (core in GLSL ES 3.00), so the
// WebGL1 fallback keeps the plain world-space band.
float eyeAaBase = renderRadius * 0.025;
#ifdef WEBGL2_IS_AVAILABLE
float scleraAa = max(eyeAaBase, fwidth(sclera));
float pupilAa = max(eyeAaBase, fwidth(pupil));
#else
float scleraAa = eyeAaBase;
float pupilAa = eyeAaBase;
#endif
if (body < minDistance) {
minDistance = body;
// Brief white punch when taking a hit.
color = mix(
readFromPalette(characterColors[i]),
vec4(1.0),
clamp(characterFlash[i], 0.0, 1.0)
);
// Paint the eyes over the body colour. The sclera albedo is HDR
// (>> 1): the shading pass multiplies it by the (often dim, reddish)
// scene light before clamping to the screen, so an over-bright white
// reads as a clean white eye everywhere instead of dimming into a
// dark socket. Needs the float colour buffer in sdf-2d; on 8-bit
// fallback it simply clamps back to plain white. The smoothstep
// widths (scleraAa / pupilAa, computed above) antialias the colour
// boundary in a zoom-aware way.
color = mix(color, vec4(10.0, 10.0, 10.0, 1.0), 1.0 - smoothstep(-scleraAa, scleraAa, sclera));
color = mix(color, vec4(0.04, 0.04, 0.07, 1.0), 1.0 - smoothstep(-pupilAa, pupilAa, pupil));
}
}
return minDistance;
}
`,
distanceFunctionName: 'characterMinDistance',
},
propertyUniformMapping: {
footRadius: 'characterFootRadii',
headRadius: 'characterHeadRadii',
rightFootCenter: 'characterRightFeet',
leftFootCenter: 'characterLeftFeet',
headCenter: 'characterHeadCenters',
gazeTarget: 'characterGazeTargets',
color: 'characterColors',
flash: 'characterFlash',
},
uniformCountMacroName: 'CHARACTER_COUNT',
shaderCombinationSteps: [0, 1, 2, 8],
empty: new CharacterShape(0),
};
protected head!: Circle;
protected leftFoot!: Circle;
protected rightFoot!: Circle;
// 0..1 transient white flash on taking a hit. Set per frame.
public hitFlash = 0;
// World point the eyes look at; the pupils slide toward it. Set per frame
// (the cursor for the local player, the travel direction for everyone else).
public gazeTarget = vec2.create();
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,
),
gazeTarget: vec2.transformMat2d(vec2.create(), this.gazeTarget, transform2d),
headRadius: this.head.radius * transform1d,
footRadius: this.leftFoot.radius * transform1d,
color: this.color,
flash: this.hitFlash,
};
}
}

View file

@ -122,8 +122,6 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
}; };
public randomOffset = 0; public randomOffset = 0;
// Radians the surface noise is rotated by; advanced over time by PlanetView so
// the planet's textured surface slowly spins.
public rotation = 0; public rotation = 0;
constructor( constructor(