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,
CommandReceiver,
CommandExecutors,
ServerAnnouncement,
} from 'shared';
import { createWorld } from './create-world';
import { DeltaTimeCalculator } from './helper/delta-time-calculator';
@ -31,6 +32,7 @@ export class GameServer extends CommandReceiver {
private declaPoints = 0;
private redPoints = 0;
private matchPointAnnounced: Partial<Record<CharacterTeam, boolean>> = {};
private isInEndGame = false;
private timeScaling = 1;
@ -52,6 +54,7 @@ export class GameServer extends CommandReceiver {
this.deltaTimes = [];
this.declaPoints = 0;
this.redPoints = 0;
this.matchPointAnnounced = {};
this.isInEndGame = false;
this.timeScaling = 1;
previousPlayers?.queueCommandForEachClient(new GameStartCommand());
@ -126,6 +129,23 @@ export class GameServer extends CommandReceiver {
this.endGame(CharacterTeam.decla);
} else if (this.redPoints >= this.options.scoreLimit) {
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,
CommandExecutors,
CommandReceiver,
mix,
clamp01,
} from 'shared';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
import { CirclePhysical } from './circle-physical';
@ -35,8 +37,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private static readonly feetRadius = 20;
private projectileStrength = settings.playerMaxStrength;
// offsets are measured from (0, 0)
private static readonly desiredHeadOffset = vec2.fromValues(0, 65);
// offsets are measured from (0, 0). The head sits this far above the feet,
// 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 desiredRightFootOffset = vec2.fromValues(20, 0);
private static readonly centerOfMass = vec2.scale(
@ -76,15 +80,21 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private isDestroyed = false;
private timeSinceBorn = 0;
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 currentPlanet?: PlanetPhysical;
private secondsSinceOnSurface = 1000;
private secondsSinceOnSurface = settings.planetDetachmentSeconds;
public head: CirclePhysical;
public leftFoot: CirclePhysical;
public rightFoot: CirclePhysical;
public bound: CirclePhysical;
private movementActions: Array<MoveActionCommand> = [];
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.leftFoot);
container.addObject(this.rightFoot);
}
this.bound = new CirclePhysical(
vec2.create(),
CharacterPhysical.boundRadius,
this,
container,
private get isSpawnProtected(): boolean {
return (
this.timeAlive <
settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds
);
}
@ -156,9 +166,33 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.movementActions.push(c);
}
public addKill() {
public get groundPlanet(): PlanetPhysical | undefined {
return this.currentPlanet;
}
public addKill(victimName: string) {
this.killCount++;
this.killStreak++;
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) {
@ -168,28 +202,54 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
other.isAlive
) {
other.destroy();
if (this.isSpawnProtected) {
return;
}
this.timeSinceLastDamage = 0;
this.health -= other.strength;
this.lastSyncedHealth = Math.round(this.health);
this.remoteCall('setHealth', this.health);
if (this.health <= 0 && this.isAlive) {
this.onDie();
other.originator.addKill();
other.originator.addKill(this.name);
} else {
other.originator.registerHit();
}
}
}
public shootTowards(position: vec2) {
if (!this.isAlive) {
public shootTowards(position: vec2, charge = 0) {
if (
!this.isAlive ||
this.timeSinceLastShot < settings.projectileCreationInterval ||
this.projectileStrength < settings.chargeShotStrengthMin
) {
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);
vec2.normalize(direction, direction);
const velocity = vec2.scale(direction, direction, settings.projectileSpeed);
const strength = this.projectileStrength / 2;
this.projectileStrength -= strength;
const velocity = vec2.scale(direction, direction, speed);
const projectile = new ProjectilePhysical(
vec2.clone(this.center),
20,
radius,
strength,
this.team,
velocity,
@ -202,8 +262,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
}
public get boundingBox(): BoundingBoxBase {
this.bound.center = this.head.center;
return this.bound.boundingBox;
return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius));
}
public get gameObject(): this {
@ -257,6 +316,11 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
new UpdatePropertyCommand('head', this.head, this.headVelocity),
new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity),
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) {
this.getPoints(game);
this.timeAlive += deltaTimeInSeconds;
const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius);
const oldLeftFoot = new Circle(
vec2.clone(this.leftFoot.center),
@ -328,16 +393,23 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
return;
}
if ((this.secondsSinceOnSurface += deltaTimeInSeconds) > 0.5) {
if (
(this.secondsSinceOnSurface += deltaTimeInSeconds) >
settings.planetDetachmentSeconds
) {
this.currentPlanet = undefined;
}
this.timeSinceLastShot += deltaTimeInSeconds;
this.projectileStrength = Math.min(
settings.playerMaxStrength,
this.projectileStrength +
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
);
this.regenerateHealth(deltaTimeInSeconds);
this.currentPlanet?.takeControl(this.team, deltaTimeInSeconds);
const intersectingWithForceField = this.container.findIntersecting(
@ -351,8 +423,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
const direction = this.averageAndResetMovementActions();
const movementForce = vec2.scale(direction, direction, settings.maxAcceleration);
this.applyForce(this.leftFoot, movementForce, deltaTimeInSeconds);
this.applyForce(this.rightFoot, movementForce, deltaTimeInSeconds);
this.leftFoot.applyForce(movementForce, deltaTimeInSeconds);
this.rightFoot.applyForce(movementForce, deltaTimeInSeconds);
if (!this.currentPlanet) {
const leftFootGravity = forceAtPosition(
@ -364,8 +436,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
intersectingWithForceField,
);
this.applyForce(this.leftFoot, leftFootGravity, deltaTimeInSeconds);
this.applyForce(this.rightFoot, rightFootGravity, deltaTimeInSeconds);
this.leftFoot.applyForce(leftFootGravity, deltaTimeInSeconds);
this.rightFoot.applyForce(rightFootGravity, deltaTimeInSeconds);
const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce);
@ -377,8 +449,11 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
vec2.add(leftFootGravity, leftFootGravity, rightFootGravity);
const gravity = vec2.scale(leftFootGravity, leftFootGravity, 0.5);
if (vec2.dot(movementForce, gravity) < -vec2.length(movementForce) * 0.8) {
vec2.scale(gravity, gravity, 0.35);
if (
vec2.dot(movementForce, gravity) <
-vec2.length(movementForce) * settings.climbDotThreshold
) {
vec2.scale(gravity, gravity, settings.climbGravityScale);
}
const scaledLeftFootGravity = vec2.scale(
@ -386,7 +461,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.leftFoot.lastNormal,
vec2.dot(this.leftFoot.lastNormal, gravity),
);
this.applyForce(this.leftFoot, scaledLeftFootGravity, deltaTimeInSeconds);
this.leftFoot.applyForce(scaledLeftFootGravity, deltaTimeInSeconds);
const scaledRightFootGravity = vec2.scale(
vec2.create(),
@ -394,15 +469,15 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
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.setDirection(gravity);
}
this.keepPosture(deltaTimeInSeconds);
this.keepPosture();
this.stepBodyPart(this.leftFoot, deltaTimeInSeconds);
this.stepBodyPart(this.rightFoot, 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;
this.springMove(
this.leftFoot,
center,
CharacterPhysical.leftFootOffset,
deltaTime,
3000,
settings.postureFeetStiffness,
);
this.springMove(
this.rightFoot,
center,
CharacterPhysical.rightFootOffset,
deltaTime,
3000,
settings.postureFeetStiffness,
);
this.springMove(this.head, center, CharacterPhysical.headOffset, deltaTime, 7000);
this.springMove(
this.head,
center,
CharacterPhysical.headOffset,
settings.postureHeadStiffness,
);
}
private springMove(
object: CirclePhysical,
center: vec2,
offset: vec2,
deltaTime: number,
strength: number,
stiffness: number,
) {
const desiredPosition = vec2.add(vec2.create(), center, offset);
vec2.rotate(desiredPosition, desiredPosition, center, this.direction);
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) {
const positionDeltaDirection = vec2.normalize(vec2.create(), positionDelta);
vec2.scale(
positionDelta,
positionDeltaDirection,
positionDeltaLength ** 2 * deltaTime * strength,
private regenerateHealth(deltaTimeInSeconds: number) {
this.timeSinceLastDamage += deltaTimeInSeconds;
if (
this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds &&
this.health < settings.playerMaxHealth
) {
this.health = Math.min(
settings.playerMaxHealth,
this.health + settings.playerHealthRegenerationPerSeconds * deltaTimeInSeconds,
);
if (vec2.length(positionDelta) * deltaTime * deltaTime > positionDeltaLength) {
vec2.scale(
positionDelta,
positionDelta,
positionDeltaLength / (vec2.length(positionDelta) * deltaTime * deltaTime),
);
}
object.applyForce(positionDelta, deltaTime);
this.syncHealth();
}
}
@ -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() {
this.isDestroyed = true;
this.remoteCall('onDie');

View file

@ -4,6 +4,7 @@ import {
clamp,
clamp01,
id,
mix,
serializesTo,
settings,
PlanetBase,
@ -18,20 +19,38 @@ import { StepCommand } from '../commands/step';
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
import { StaticPhysical } from '../physics/physicals/static-physical';
import { LampPhysical } from './lamp-physical';
@serializesTo(PlanetBase)
export class PlanetPhysical extends PlanetBase implements StaticPhysical {
public readonly canCollide = true;
public readonly canMove = false;
public readonly sizePointMultiplier: number;
private _boundingBox?: ImmutableBoundingBox;
private readonly lamps: Array<LampPhysical> = [];
private lastTeam: CharacterTeam = CharacterTeam.neutral;
protected commandExecutors: CommandExecutors = {
[StepCommand.type]: this.step.bind(this),
};
public addLamp(lamp: LampPhysical) {
this.lamps.push(lamp);
}
constructor(vertices: Array<vec2>) {
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 {
@ -80,14 +99,14 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
private getPoints(game: CommandReceiver) {
if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) {
this.timeSinceLastPointGeneration = 0;
if (this.team !== CharacterTeam.neutral) {
this.remoteCall('generatedPoints', settings.planetPointGenerationValue);
}
const value = Math.round(
settings.planetPointGenerationValue * this.sizePointMultiplier,
);
game.handleCommand(
new GeneratePointsCommand(
this.team === CharacterTeam.decla ? settings.planetPointGenerationValue : 0,
this.team === CharacterTeam.red ? settings.planetPointGenerationValue : 0,
this.team === CharacterTeam.decla ? value : 0,
this.team === CharacterTeam.red ? value : 0,
),
);
}
@ -95,9 +114,40 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
private step({ deltaTimeInSeconds, game }: StepCommand) {
this.timeSinceLastPointGeneration += deltaTimeInSeconds;
// In reverse order, so that teams can achieve a 100% control.
this.getPoints(game);
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 {
@ -153,7 +203,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
public getForce(position: vec2): vec2 {
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);
const scale = clamp(
settings.maxGravityQ * ((settings.maxGravityDistance / dist) ** 1.5 - 1),

View file

@ -6,6 +6,7 @@ import {
Random,
MoveActionCommand,
CharacterTeam,
Id,
} from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-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 { CharacterPhysical } from '../objects/character-physical';
import { PlanetPhysical } from '../objects/planet-physical';
import { ProjectilePhysical } from '../objects/projectile-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 {
private direction: vec2 = vec2.fromValues(Random.getRandom(), Random.getRandom());
private timeSinceLastFindTarget = 10000;
private timeSinceLastFindShootTarget = 10000;
private direction = vec2.fromValues(Random.getRandom() - 0.5, Random.getRandom() - 0.5);
private timeSinceLastPlan = 10000;
private timeSinceLastShoot = 10000;
private isWandering = false;
private timeSinceLastWanderingConsideration = 0;
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(
playerInfo: PlayerInformation,
playerContainer: PlayerContainer,
@ -34,172 +96,304 @@ export class NPC extends PlayerBase {
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;
public step(deltaTimeInSeconds: number) {
if (this.character) {
this.center = this.character?.center;
this.center = this.character.center;
if (!this.character.isAlive) {
this.sumDeaths++;
this.sumKills = this.character.killCount;
this.character = null;
this.timeUntilRespawn = settings.playerDiedTimeout;
return;
}
} else {
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
this.createCharacter();
this.center = this.character!.center;
}
return;
}
if ((this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) > 3) {
if (
(this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) >
npcTuning.wanderReconsiderSeconds
) {
this.timeSinceLastWanderingConsideration = 0;
this.isWandering = Random.getRandom() > 0.5;
this.isWandering = Random.getRandom() < npcTuning.wanderProbability;
}
if ((this.timeSinceLastFindTarget += deltaTimeInSeconds) > 1) {
this.timeSinceLastFindTarget = 0;
this.findTarget();
if ((this.timeSinceLastPlan += deltaTimeInSeconds) > npcTuning.planIntervalSeconds) {
this.timeSinceLastPlan = 0;
this.plan();
}
if ((this.timeSinceLastFindShootTarget += deltaTimeInSeconds) > 0.5) {
if (Random.getRandom() > 0.5) {
const shootTarget = this.findShootTarget();
if (shootTarget) {
vec2.add(
shootTarget,
shootTarget,
vec2.fromValues(
Random.getRandomInRange(-200, 200),
Random.getRandomInRange(-200, 200),
),
);
this.character?.shootTowards(shootTarget);
}
}
this.timeSinceLastFindShootTarget = 0;
if (
(this.timeSinceObserve += deltaTimeInSeconds) > npcTuning.reactionIntervalSeconds
) {
this.timeSinceObserve = 0;
this.nearObjects = this.observe(npcTuning.reactionObserveRadius);
}
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() {
const randomPoint = vec2.rotate(
private plan() {
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.fromValues(Random.getRandomInRange(0, settings.worldRadius), 0),
vec2.create(),
Random.getRandomInRange(0, Math.PI * 2),
Random.getRandomInRange(-npcTuning.wanderTurn, npcTuning.wanderTurn),
);
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;
}
}