437 lines
12 KiB
TypeScript
437 lines
12 KiB
TypeScript
import { vec2 } from 'gl-matrix';
|
|
import {
|
|
id,
|
|
settings,
|
|
Circle,
|
|
CharacterBase,
|
|
CharacterTeam,
|
|
PropertyUpdatesForObject,
|
|
UpdatePropertyCommand,
|
|
CharacterMovementSnapshot,
|
|
GameObject,
|
|
mix,
|
|
clamp01,
|
|
strengthToCharge,
|
|
stepCharacterMovement,
|
|
applyLeapImpulse,
|
|
decayMomentum,
|
|
tickPlanetDetachment,
|
|
characterCenter,
|
|
sumGravity,
|
|
CharacterWorld,
|
|
GroundSurface,
|
|
headRadius,
|
|
feetRadius,
|
|
headOffset,
|
|
leftFootOffset,
|
|
rightFootOffset,
|
|
boundRadius,
|
|
} from 'shared';
|
|
import { Physical } from '../physics/physical';
|
|
import { CirclePhysical } from './circle-physical';
|
|
import { PhysicalContainer } from '../physics/physical-container';
|
|
import { BoundingBox } from '../physics/bounding-box';
|
|
import { ProjectilePhysical } from './projectile-physical';
|
|
import { PlanetPhysical, planetsIn } from './planet-physical';
|
|
|
|
const placeholder = new Circle(vec2.create(), 0);
|
|
|
|
export class CharacterPhysical extends CharacterBase implements Physical {
|
|
public readonly canCollide = true;
|
|
|
|
public head: CirclePhysical;
|
|
public leftFoot: CirclePhysical;
|
|
public rightFoot: CirclePhysical;
|
|
|
|
public direction = 0;
|
|
public currentPlanet: GroundSurface | undefined;
|
|
public secondsSinceOnSurface = settings.planetDetachmentSeconds;
|
|
public readonly bodyVelocity = vec2.create();
|
|
|
|
private projectileStrength = settings.playerMaxStrength;
|
|
private timeAlive = 0;
|
|
private timeSinceDying = 0;
|
|
private isDestroyed = false;
|
|
private hasJustBorn = true;
|
|
private hasFiredSinceSpawn = false;
|
|
private timeSinceLastShot = settings.projectileCreationInterval;
|
|
private timeSinceLastLeap = settings.leapCooldownSeconds;
|
|
private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds;
|
|
private lastSyncedHealth = settings.playerMaxHealth;
|
|
private killStreak = 0;
|
|
|
|
private movementDirections: Array<vec2> = [];
|
|
private lastMovementDirection = vec2.create();
|
|
|
|
private readonly previousPose = [0, 1, 2].map(() => new Circle(vec2.create(), 0));
|
|
private readonly velocities = [0, 1, 2].map(() => new Circle(vec2.create(), 0));
|
|
private readonly box = new BoundingBox();
|
|
|
|
constructor(
|
|
name: string,
|
|
killCount: number,
|
|
deathCount: number,
|
|
team: CharacterTeam,
|
|
private readonly container: PhysicalContainer,
|
|
startPosition: vec2,
|
|
) {
|
|
super(
|
|
id(),
|
|
name,
|
|
killCount,
|
|
deathCount,
|
|
team,
|
|
settings.playerMaxHealth,
|
|
placeholder,
|
|
placeholder,
|
|
placeholder,
|
|
);
|
|
const part = (offset: vec2, radius: number) =>
|
|
new CirclePhysical(
|
|
vec2.add(vec2.create(), startPosition, offset),
|
|
radius,
|
|
this,
|
|
container,
|
|
);
|
|
this.head = part(headOffset, headRadius);
|
|
this.leftFoot = part(leftFootOffset, feetRadius);
|
|
this.rightFoot = part(rightFootOffset, feetRadius);
|
|
}
|
|
|
|
private readonly movementWorld: CharacterWorld = {
|
|
groundsNear: (center, radius) =>
|
|
planetsIn(this.container.findIntersecting(BoundingBox.ofCircle(center, radius))),
|
|
stepBody: (body, deltaTimeInSeconds) => {
|
|
const hit = (body as CirclePhysical).stepManually(deltaTimeInSeconds);
|
|
return hit instanceof PlanetPhysical ? hit : undefined;
|
|
},
|
|
};
|
|
|
|
private get parts(): Array<CirclePhysical> {
|
|
return [this.head, this.leftFoot, this.rightFoot];
|
|
}
|
|
|
|
private get isSpawnProtected(): boolean {
|
|
return (
|
|
!this.hasFiredSinceSpawn &&
|
|
this.timeAlive <
|
|
settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds
|
|
);
|
|
}
|
|
|
|
public get isAlive(): boolean {
|
|
return !this.isDestroyed;
|
|
}
|
|
|
|
public get groundPlanet(): PlanetPhysical | undefined {
|
|
return this.currentPlanet as PlanetPhysical | undefined;
|
|
}
|
|
|
|
public get movementSnapshot(): CharacterMovementSnapshot {
|
|
return new CharacterMovementSnapshot(
|
|
this.direction,
|
|
vec2.clone(this.bodyVelocity),
|
|
vec2.clone(this.leftFoot.lastNormal),
|
|
vec2.clone(this.rightFoot.lastNormal),
|
|
this.groundPlanet?.id ?? null,
|
|
this.secondsSinceOnSurface,
|
|
);
|
|
}
|
|
|
|
public get boundingBox(): BoundingBox {
|
|
const center = this.center;
|
|
this.box.xMin = center[0] - boundRadius;
|
|
this.box.xMax = center[0] + boundRadius;
|
|
this.box.yMin = center[1] - boundRadius;
|
|
this.box.yMax = center[1] + boundRadius;
|
|
return this.box;
|
|
}
|
|
|
|
public get gameObject(): this {
|
|
return this;
|
|
}
|
|
|
|
public get center(): vec2 {
|
|
return characterCenter(this.head, this.leftFoot, this.rightFoot);
|
|
}
|
|
|
|
public distance(target: vec2): number {
|
|
return (
|
|
Math.min(
|
|
this.head.distance(target),
|
|
this.leftFoot.distance(target),
|
|
this.rightFoot.distance(target),
|
|
) - 5
|
|
);
|
|
}
|
|
|
|
public setMoveDirection(direction: vec2) {
|
|
this.movementDirections.push(direction);
|
|
}
|
|
|
|
public addKill(victimName: string, charge = 0) {
|
|
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, charge);
|
|
}
|
|
|
|
public registerHit(charge = 0) {
|
|
this.remoteCall('onHitConfirmed', charge);
|
|
}
|
|
|
|
private syncHealth() {
|
|
const rounded = Math.round(this.health);
|
|
if (rounded !== this.lastSyncedHealth) {
|
|
this.lastSyncedHealth = rounded;
|
|
this.remoteCall('setHealth', this.health);
|
|
}
|
|
}
|
|
|
|
public onCollision(other: GameObject) {
|
|
if (
|
|
!this.isAlive ||
|
|
!(other instanceof ProjectilePhysical) ||
|
|
other.team === this.team ||
|
|
!other.isAlive
|
|
) {
|
|
return;
|
|
}
|
|
|
|
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) {
|
|
vec2.scaleAndAdd(
|
|
this.bodyVelocity,
|
|
this.bodyVelocity,
|
|
other.direction,
|
|
mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge),
|
|
);
|
|
this.onDie(true);
|
|
other.originator.addKill(this.name, other.charge);
|
|
} else {
|
|
other.originator.registerHit(other.charge);
|
|
}
|
|
}
|
|
|
|
public shootTowards(position: vec2, charge = 0) {
|
|
if (
|
|
!this.isAlive ||
|
|
this.timeSinceLastShot < settings.projectileCreationInterval ||
|
|
this.projectileStrength < settings.chargeShotStrengthMin
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const direction = vec2.subtract(vec2.create(), position, this.center);
|
|
if (vec2.length(direction) === 0) {
|
|
return;
|
|
}
|
|
|
|
this.timeSinceLastShot = 0;
|
|
this.hasFiredSinceSpawn = true;
|
|
|
|
const desiredStrength = mix(
|
|
settings.chargeShotStrengthMin,
|
|
settings.chargeShotStrengthMax,
|
|
clamp01(charge),
|
|
);
|
|
const strength = Math.min(desiredStrength, this.projectileStrength);
|
|
this.projectileStrength -= strength;
|
|
|
|
const c = strengthToCharge(strength);
|
|
const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c);
|
|
const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c);
|
|
|
|
vec2.normalize(direction, direction);
|
|
this.container.addObject(
|
|
new ProjectilePhysical(
|
|
vec2.clone(this.center),
|
|
radius,
|
|
strength,
|
|
this.team,
|
|
vec2.scale(vec2.create(), direction, speed),
|
|
this,
|
|
this.container,
|
|
c,
|
|
),
|
|
);
|
|
|
|
if (c > 0) {
|
|
vec2.scaleAndAdd(
|
|
this.bodyVelocity,
|
|
this.bodyVelocity,
|
|
direction,
|
|
-settings.chargeShotRecoilMax * c,
|
|
);
|
|
}
|
|
|
|
this.remoteCall('onShoot', strength);
|
|
}
|
|
|
|
public leap() {
|
|
if (
|
|
!this.isAlive ||
|
|
this.hasJustBorn ||
|
|
!this.currentPlanet ||
|
|
this.timeSinceLastLeap < settings.leapCooldownSeconds ||
|
|
this.projectileStrength < settings.leapStrengthCost
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.timeSinceLastLeap = 0;
|
|
this.projectileStrength -= settings.leapStrengthCost;
|
|
applyLeapImpulse(this, this.lastMovementDirection);
|
|
this.remoteCall('onLeap');
|
|
}
|
|
|
|
public onDie(killedInCombat = false) {
|
|
if (this.isDestroyed) {
|
|
return;
|
|
}
|
|
this.isDestroyed = true;
|
|
this.remoteCall('onDie');
|
|
|
|
if (killedInCombat) {
|
|
const points = settings.playerKillPoint;
|
|
this.container.game.addPoints(
|
|
this.team === CharacterTeam.blue ? 0 : points,
|
|
this.team === CharacterTeam.red ? 0 : points,
|
|
);
|
|
}
|
|
}
|
|
|
|
public getPropertyUpdates(): PropertyUpdatesForObject {
|
|
const [headVelocity, leftFootVelocity, rightFootVelocity] = this.velocities;
|
|
return new PropertyUpdatesForObject(this.id, [
|
|
new UpdatePropertyCommand('head', this.head, headVelocity),
|
|
new UpdatePropertyCommand('leftFoot', this.leftFoot, leftFootVelocity),
|
|
new UpdatePropertyCommand('rightFoot', this.rightFoot, rightFootVelocity),
|
|
new UpdatePropertyCommand(
|
|
'strength',
|
|
this.projectileStrength,
|
|
settings.playerStrengthRegenerationPerSeconds,
|
|
),
|
|
]);
|
|
}
|
|
|
|
public step(deltaTimeInSeconds: number) {
|
|
this.timeAlive += deltaTimeInSeconds;
|
|
this.timeSinceLastLeap += deltaTimeInSeconds;
|
|
this.parts.forEach((part, i) => {
|
|
vec2.copy(this.previousPose[i].center, part.center);
|
|
this.previousPose[i].radius = part.radius;
|
|
});
|
|
|
|
if (this.isDestroyed) {
|
|
if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) {
|
|
this.container.removeObject(this);
|
|
} else {
|
|
this.freeFallCorpse(deltaTimeInSeconds);
|
|
this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime);
|
|
}
|
|
} else if (this.hasJustBorn) {
|
|
if (this.timeAlive > settings.spawnDespawnTime) {
|
|
this.hasJustBorn = false;
|
|
this.animateScaling(1);
|
|
} else {
|
|
this.animateScaling(this.timeAlive / settings.spawnDespawnTime);
|
|
}
|
|
} else {
|
|
this.stepAlive(deltaTimeInSeconds);
|
|
}
|
|
|
|
this.parts.forEach((part, i) => {
|
|
const velocity = this.velocities[i];
|
|
const previous = this.previousPose[i];
|
|
vec2.subtract(velocity.center, part.center, previous.center);
|
|
vec2.scale(velocity.center, velocity.center, 1 / deltaTimeInSeconds);
|
|
velocity.radius = (part.radius - previous.radius) / deltaTimeInSeconds;
|
|
});
|
|
}
|
|
|
|
private stepAlive(deltaTimeInSeconds: number) {
|
|
tickPlanetDetachment(this, deltaTimeInSeconds);
|
|
|
|
this.timeSinceLastShot += deltaTimeInSeconds;
|
|
this.projectileStrength = Math.min(
|
|
settings.playerMaxStrength,
|
|
this.projectileStrength +
|
|
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
|
|
);
|
|
|
|
this.timeSinceLastDamage += deltaTimeInSeconds;
|
|
if (
|
|
this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds &&
|
|
this.health < settings.playerMaxHealth
|
|
) {
|
|
this.health = Math.min(
|
|
settings.playerMaxHealth,
|
|
this.health + settings.playerHealthRegenerationPerSeconds * deltaTimeInSeconds,
|
|
);
|
|
this.syncHealth();
|
|
}
|
|
|
|
this.groundPlanet?.registerPresence(this);
|
|
|
|
stepCharacterMovement(
|
|
this,
|
|
this.movementWorld,
|
|
this.averageAndResetMovementDirections(),
|
|
deltaTimeInSeconds,
|
|
);
|
|
}
|
|
|
|
private averageAndResetMovementDirections(): vec2 {
|
|
const direction = vec2.create();
|
|
if (this.movementDirections.length === 0) {
|
|
vec2.copy(direction, this.lastMovementDirection);
|
|
} else {
|
|
for (const d of this.movementDirections) {
|
|
vec2.add(direction, direction, d);
|
|
}
|
|
vec2.scale(direction, direction, 1 / this.movementDirections.length);
|
|
this.lastMovementDirection =
|
|
this.movementDirections[this.movementDirections.length - 1];
|
|
this.movementDirections = [];
|
|
}
|
|
return vec2.normalize(direction, direction);
|
|
}
|
|
|
|
private animateScaling(q: number) {
|
|
this.head.radius = headRadius * q;
|
|
this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;
|
|
}
|
|
|
|
private freeFallCorpse(deltaTime: number) {
|
|
const planets = this.movementWorld.groundsNear(
|
|
this.center,
|
|
boundRadius + settings.maxGravityDistance,
|
|
);
|
|
let grounded = false;
|
|
for (const part of this.parts) {
|
|
vec2.copy(part.velocity, this.bodyVelocity);
|
|
part.applyForce(sumGravity(planets, part.center), deltaTime);
|
|
if (part.stepManually(deltaTime) instanceof PlanetPhysical) {
|
|
grounded = true;
|
|
}
|
|
}
|
|
decayMomentum(this.bodyVelocity, grounded, deltaTime);
|
|
}
|
|
}
|