More improvements
This commit is contained in:
parent
3848e460cd
commit
e3c44f775b
11 changed files with 908 additions and 233 deletions
|
|
@ -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>!`,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
||||||
|
|
|
||||||
219
frontend/src/scripts/shapes/character-shape.ts
Normal file
219
frontend/src/scripts/shapes/character-shape.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue