Compare commits
4 commits
7412bc8af5
...
ec579650d7
| Author | SHA1 | Date | |
|---|---|---|---|
| ec579650d7 | |||
| e3c44f775b | |||
| 3848e460cd | |||
| 793d9a81e8 |
43 changed files with 2184 additions and 561 deletions
|
|
@ -46,5 +46,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
|||
CMD node -e "require('http').get('http://localhost:3000/state',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["node", "dist/main.js"]
|
||||
# Override these to tune the server, e.g. `--name`, `--playerLimit`, `--worldSize`.
|
||||
# Override these to tune the server, e.g. `--name`, `--playerLimit`, `--scoreLimit`.
|
||||
CMD ["--port", "3000"]
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ import { PhysicalContainer } from './physics/containers/physical-container';
|
|||
import { evaluateSdf } from './physics/functions/evaluate-sdf';
|
||||
import { Physical } from './physics/physicals/physical';
|
||||
|
||||
export const createWorld = (objectContainer: PhysicalContainer, worldRadius: number) => {
|
||||
export const createWorld = (objectContainer: PhysicalContainer) => {
|
||||
const objects: Array<Physical> = [];
|
||||
const lights: Array<Physical> = [];
|
||||
|
||||
for (let r = 0; r < worldRadius; r += settings.radiusSteps) {
|
||||
for (let r = 0; r < settings.worldRadius; r += settings.radiusSteps) {
|
||||
const circumference = 2 * Math.PI * r;
|
||||
const stepCount = circumference * settings.objectsOnCircleLength;
|
||||
for (let rad = 0; rad < 2 * Math.PI; rad += (2 * Math.PI) / stepCount) {
|
||||
|
|
@ -67,8 +67,29 @@ export const createWorld = (objectContainer: PhysicalContainer, worldRadius: num
|
|||
}
|
||||
}
|
||||
}
|
||||
console.info('Generated planet count', objects.length);
|
||||
console.info('Generated light count', lights.length);
|
||||
console.info(`Generated ${objects.length} planets`);
|
||||
console.info(`Generated ${lights.length} light`);
|
||||
|
||||
// Associate each lamp with its NEAREST planet, so a planet can repaint "its"
|
||||
// lamps to the owning team's colour when it flips. Lamps are already placed by
|
||||
// proximity during world-gen, so the nearest planet is the one whose capture
|
||||
// they should advertise. Distances use the planet SDF (negative inside), which
|
||||
// is exactly the "closest planet" metric we want.
|
||||
const planets = objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
|
||||
lights
|
||||
.filter((l): l is LampPhysical => l instanceof LampPhysical)
|
||||
.forEach((lamp) => {
|
||||
let nearest: PlanetPhysical | undefined;
|
||||
let nearestDistance = Infinity;
|
||||
planets.forEach((planet) => {
|
||||
const distance = planet.distance(lamp.center);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearest = planet;
|
||||
}
|
||||
});
|
||||
nearest?.addLamp(lamp);
|
||||
});
|
||||
|
||||
[...objects, ...lights].forEach((o) => objectContainer.addObject(o));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ export const defaultOptions: Options = {
|
|||
port: 3000,
|
||||
name: 'Test server',
|
||||
playerLimit: 16,
|
||||
npcCount: 16,
|
||||
npcCount: 8,
|
||||
seed: Math.random(),
|
||||
scoreLimit: 1000,
|
||||
worldSize: 8000,
|
||||
scoreLimit: 2500,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -41,7 +43,7 @@ export class GameServer extends CommandReceiver {
|
|||
private initialize() {
|
||||
const previousPlayers = this.players;
|
||||
this.objects = new PhysicalContainer();
|
||||
createWorld(this.objects, this.options.worldSize);
|
||||
createWorld(this.objects);
|
||||
this.objects.initialize();
|
||||
this.players = new PlayerContainer(
|
||||
this.objects,
|
||||
|
|
@ -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>!`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
this.health -= other.strength;
|
||||
this.remoteCall('setHealth', this.health);
|
||||
if (this.health <= 0 && this.isAlive) {
|
||||
this.onDie();
|
||||
other.originator.addKill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public shootTowards(position: vec2) {
|
||||
if (!this.isAlive) {
|
||||
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(this.name);
|
||||
} else {
|
||||
other.originator.registerHit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
if (positionDeltaLength > 0) {
|
||||
const positionDeltaDirection = vec2.normalize(vec2.create(), positionDelta);
|
||||
vec2.scale(
|
||||
positionDelta,
|
||||
positionDeltaDirection,
|
||||
positionDeltaLength ** 2 * deltaTime * strength,
|
||||
);
|
||||
|
||||
if (vec2.length(positionDelta) * deltaTime * deltaTime > positionDeltaLength) {
|
||||
vec2.scale(
|
||||
positionDelta,
|
||||
positionDelta,
|
||||
positionDeltaLength / (vec2.length(positionDelta) * deltaTime * deltaTime),
|
||||
);
|
||||
// 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);
|
||||
}
|
||||
|
||||
object.applyForce(positionDelta, deltaTime);
|
||||
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,
|
||||
);
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ export class LampPhysical extends LampBase implements StaticPhysical {
|
|||
return this;
|
||||
}
|
||||
|
||||
public queueSetLight(color: vec3, lightness: number) {
|
||||
this.remoteCall('setLight', color, lightness);
|
||||
}
|
||||
|
||||
public distance(target: vec2): number {
|
||||
return vec2.distance(this.center, target);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -53,6 +53,13 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
|||
return !this.isDestroyed;
|
||||
}
|
||||
|
||||
public get direction(): vec2 {
|
||||
const direction = vec2.clone(this.velocity);
|
||||
return vec2.length(direction) > 0
|
||||
? vec2.normalize(direction, direction)
|
||||
: vec2.fromValues(0, -1);
|
||||
}
|
||||
|
||||
private moveOutsideOfObject() {
|
||||
let wasCollision = true;
|
||||
const delta = vec2.scale(
|
||||
|
|
|
|||
|
|
@ -5,5 +5,4 @@ export interface Options {
|
|||
scoreLimit: number;
|
||||
npcCount: number;
|
||||
seed: number;
|
||||
worldSize: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,305 @@ 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);
|
||||
if (
|
||||
(this.timeSinceObserve += deltaTimeInSeconds) > npcTuning.reactionIntervalSeconds
|
||||
) {
|
||||
this.timeSinceObserve = 0;
|
||||
this.nearObjects = this.observe(npcTuning.reactionObserveRadius);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
this.timeSinceLastFindShootTarget = 0;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
this.character?.handleMovementAction(new MoveActionCommand(this.direction));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
protected createCharacter() {
|
||||
const randomPoint = vec2.rotate(
|
||||
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);
|
||||
|
||||
const spread =
|
||||
(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,9 +1,17 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { CommandReceiver, Circle, PlayerInformation, CharacterTeam } from 'shared';
|
||||
import {
|
||||
CommandReceiver,
|
||||
Circle,
|
||||
PlayerInformation,
|
||||
CharacterTeam,
|
||||
Random,
|
||||
settings,
|
||||
} from 'shared';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
import { isCircleIntersecting } from '../physics/functions/is-circle-intersecting';
|
||||
import { CharacterPhysical } from '../objects/character-physical';
|
||||
import { PlanetPhysical } from '../objects/planet-physical';
|
||||
import { PlayerContainer } from './player-container';
|
||||
|
||||
export abstract class PlayerBase extends CommandReceiver {
|
||||
|
|
@ -22,14 +30,14 @@ export abstract class PlayerBase extends CommandReceiver {
|
|||
super();
|
||||
}
|
||||
|
||||
protected createCharacter(preferredCenter: vec2) {
|
||||
protected createCharacter() {
|
||||
this.character = new CharacterPhysical(
|
||||
this.playerInfo.name.slice(0, 20),
|
||||
this.sumKills,
|
||||
this.sumDeaths,
|
||||
this.team,
|
||||
this.objectContainer,
|
||||
this.findEmptyPositionForPlayer(preferredCenter),
|
||||
this.findEmptyPositionForPlayer(this.findSpawnCenter()),
|
||||
);
|
||||
|
||||
this.objectContainer.addObject(this.character);
|
||||
|
|
@ -37,14 +45,37 @@ export abstract class PlayerBase extends CommandReceiver {
|
|||
|
||||
public abstract step(deltaTimeInSeconds: number): void;
|
||||
|
||||
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
|
||||
if (!preferredCenter) {
|
||||
preferredCenter = vec2.create();
|
||||
private findSpawnCenter(): vec2 {
|
||||
const planets = this.objectContainer
|
||||
.findIntersecting(
|
||||
getBoundingBoxOfCircle(new Circle(vec2.create(), settings.worldRadius * 2)),
|
||||
)
|
||||
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
|
||||
|
||||
const friendly = planets.filter((p) => p.team === this.team);
|
||||
const neutral = planets.filter((p) => p.team === CharacterTeam.neutral);
|
||||
const candidates = friendly.length ? friendly : neutral.length ? neutral : planets;
|
||||
if (candidates.length === 0) {
|
||||
return vec2.create();
|
||||
}
|
||||
|
||||
const isContested = (planet: PlanetPhysical) =>
|
||||
this.playerContainer.players.some(
|
||||
(p) =>
|
||||
p.team !== this.team &&
|
||||
p.character?.isAlive &&
|
||||
vec2.distance(p.center, planet.center) < settings.spawnSafetyDistance,
|
||||
);
|
||||
const safe = candidates.filter((p) => !isContested(p));
|
||||
|
||||
// candidates is non-empty here, so choose() always returns a planet.
|
||||
return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center);
|
||||
}
|
||||
|
||||
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
|
||||
let rotation = 0;
|
||||
let radius = 0;
|
||||
for (;;) {
|
||||
for (; ;) {
|
||||
const playerPosition = vec2.fromValues(
|
||||
radius * Math.cos(rotation) + preferredCenter.x,
|
||||
radius * Math.sin(rotation) + preferredCenter.y,
|
||||
|
|
|
|||
|
|
@ -42,9 +42,8 @@ export class Player extends PlayerBase {
|
|||
(this.aspectRatio = v.aspectRatio),
|
||||
[MoveActionCommand.type]: (c: MoveActionCommand) =>
|
||||
this.character?.handleMovementAction(c),
|
||||
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) => {
|
||||
this.character?.shootTowards(c.position);
|
||||
},
|
||||
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) =>
|
||||
this.character?.shootTowards(c.position, c.charge),
|
||||
};
|
||||
|
||||
constructor(
|
||||
|
|
@ -60,11 +59,7 @@ export class Player extends PlayerBase {
|
|||
}
|
||||
|
||||
protected createCharacter() {
|
||||
const preferredCenter = this.playerContainer.players.find(
|
||||
(p) => p.character?.isAlive && p.team === this.team,
|
||||
)?.center;
|
||||
|
||||
super.createCharacter(preferredCenter ?? vec2.create());
|
||||
super.createCharacter();
|
||||
|
||||
this.objectsPreviouslyInViewArea.push(this.character!);
|
||||
this.queueCommandSend(new CreatePlayerCommand(this.character!));
|
||||
|
|
@ -206,8 +201,4 @@ export class Player extends PlayerBase {
|
|||
this.queueCommandSend(new ServerAnnouncement(announcement));
|
||||
}
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
import { Command, GameObject } from 'shared';
|
||||
|
||||
export class ReactToCollisionCommand extends Command {
|
||||
public constructor(public readonly other: GameObject) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
47
frontend/package-lock.json
generated
47
frontend/package-lock.json
generated
|
|
@ -23,7 +23,7 @@
|
|||
"resize-observer-polyfill": "^1.5.1",
|
||||
"sass": "^1.100.0",
|
||||
"sass-loader": "^17.0.0",
|
||||
"sdf-2d": "^0.7.6",
|
||||
"sdf-2d": "file:../../sdf-2d",
|
||||
"shared": "file:../shared",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"socket.io-msgpack-parser": "^3.0.2",
|
||||
|
|
@ -36,6 +36,40 @@
|
|||
"webpack-dev-server": "^5.2.4"
|
||||
}
|
||||
},
|
||||
"../../sdf-2d": {
|
||||
"version": "0.7.6",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"gl-matrix": "^3.4.4",
|
||||
"resize-observer-polyfill": "^1.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"raw-loader": "^4.0.2",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
"ts-loader": "^9.6.0",
|
||||
"typedoc": "^0.28.19",
|
||||
"typedoc-plugin-extras": "^4.0.1",
|
||||
"typescript": "^6.0.3",
|
||||
"webpack": "^5.107.2",
|
||||
"webpack-cli": "^7.0.3"
|
||||
}
|
||||
},
|
||||
"../../sdf-2d/dist": {
|
||||
"extraneous": true
|
||||
},
|
||||
"../../sdf-2d/lib": {
|
||||
"extraneous": true
|
||||
},
|
||||
"../shared": {
|
||||
"version": "0.0.0",
|
||||
"dev": true,
|
||||
|
|
@ -5409,15 +5443,8 @@
|
|||
}
|
||||
},
|
||||
"node_modules/sdf-2d": {
|
||||
"version": "0.7.6",
|
||||
"resolved": "https://registry.npmjs.org/sdf-2d/-/sdf-2d-0.7.6.tgz",
|
||||
"integrity": "sha512-aBJUjwYjWP+/fSvLHH6vhpFyWeSJNCSEHpE0XBX69s2bQlo/NOUr3nq/KuhRZ6A3FeXrGU4lzyurHJ1N8f4rFg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"gl-matrix": "^3.3.0",
|
||||
"resize-observer-polyfill": "^1.5.1"
|
||||
}
|
||||
"resolved": "../../sdf-2d",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/select-hose": {
|
||||
"version": "2.0.0",
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
"resize-observer-polyfill": "^1.5.1",
|
||||
"sass": "^1.100.0",
|
||||
"sass-loader": "^17.0.0",
|
||||
"sdf-2d": "^0.7.6",
|
||||
"sdf-2d": "^0.8.0",
|
||||
"shared": "file:../shared",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"socket.io-msgpack-parser": "^3.0.2",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
|
||||
html {
|
||||
font-size: 0.85rem;
|
||||
|
||||
@media (max-width: $breakpoint) {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
|
@ -30,17 +31,21 @@ img {
|
|||
}
|
||||
|
||||
h1 {
|
||||
|
||||
&,
|
||||
* {
|
||||
font-family: 'Comfortaa', sans-serif;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
font-size: 6rem;
|
||||
text-align: center;
|
||||
padding-bottom: $medium-padding;
|
||||
|
||||
@media (max-width: $height-breakpoint) {
|
||||
font-size: 4.5rem;
|
||||
}
|
||||
|
||||
@media (max-height: $height-breakpoint) {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -48,6 +53,7 @@ h1 {
|
|||
|
||||
.red {
|
||||
color: $accent;
|
||||
|
||||
&::selection {
|
||||
color: $accent;
|
||||
background-color: white;
|
||||
|
|
@ -75,8 +81,10 @@ body {
|
|||
top: 0;
|
||||
|
||||
@include background;
|
||||
|
||||
#spinner {
|
||||
@include square(20vmax);
|
||||
|
||||
@media (max-width: $breakpoint) {
|
||||
@include square(20vmax);
|
||||
}
|
||||
|
|
@ -107,7 +115,7 @@ body {
|
|||
|
||||
overflow: hidden;
|
||||
|
||||
& > * {
|
||||
&>* {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
@ -120,8 +128,10 @@ body {
|
|||
|
||||
&.decla {
|
||||
color: $bright-decla;
|
||||
div:first-child {
|
||||
|
||||
.health {
|
||||
background-color: $bright-decla;
|
||||
|
||||
&:before {
|
||||
background-color: $bright-decla;
|
||||
opacity: 0.3;
|
||||
|
|
@ -131,8 +141,10 @@ body {
|
|||
|
||||
&.red {
|
||||
color: $bright-red;
|
||||
div:first-child {
|
||||
|
||||
.health {
|
||||
background-color: $bright-red;
|
||||
|
||||
&:before {
|
||||
background-color: $bright-red;
|
||||
opacity: 0.4;
|
||||
|
|
@ -140,7 +152,7 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
div:first-child {
|
||||
.health {
|
||||
position: relative;
|
||||
height: 5px;
|
||||
border-radius: 1000px;
|
||||
|
|
@ -155,9 +167,33 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
div:not(:first-child) {
|
||||
letter-spacing: 2px;
|
||||
.charge {
|
||||
height: 3px;
|
||||
margin-top: 2px;
|
||||
border-radius: 1000px;
|
||||
background-color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 700;
|
||||
text-shadow: 0 0 4px rgba(0, 0, 0, 0.9);
|
||||
|
||||
.stat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.1em;
|
||||
height: 1.1em;
|
||||
fill: currentColor;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -172,9 +208,13 @@ body {
|
|||
|
||||
.falling-point {
|
||||
font-size: 1.2em;
|
||||
font-weight: 700;
|
||||
animation: falling-point 2s ease-out forwards;
|
||||
|
||||
&.decla {
|
||||
color: $bright-decla;
|
||||
}
|
||||
|
||||
&.red {
|
||||
color: $bright-red;
|
||||
}
|
||||
|
|
@ -184,6 +224,7 @@ body {
|
|||
transition: transform 150ms;
|
||||
|
||||
@include square($large-icon);
|
||||
|
||||
@media (max-width: $breakpoint) {
|
||||
@include square($small-icon);
|
||||
}
|
||||
|
|
@ -219,6 +260,56 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
.touch-button {
|
||||
display: none;
|
||||
pointer-events: auto;
|
||||
touch-action: none;
|
||||
cursor: pointer;
|
||||
|
||||
$size: $large-icon * 1.6;
|
||||
@include square($size);
|
||||
top: auto;
|
||||
left: auto;
|
||||
bottom: $medium-padding;
|
||||
border-radius: 1000px;
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: inset 0 0 8px 3px rgba(0, 0, 0, 0.33);
|
||||
border: $border-width solid rgba(255, 255, 255, 0.4);
|
||||
|
||||
font-size: 1.6rem;
|
||||
line-height: $size - 2 * $border-width;
|
||||
text-align: center;
|
||||
text-shadow: 0 0 6px rgba(0, 0, 0, 0.8);
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&.fire {
|
||||
right: $medium-padding;
|
||||
|
||||
&::after {
|
||||
content: '\25C9';
|
||||
}
|
||||
}
|
||||
|
||||
.strength-ring {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
width: calc(100% + 6px);
|
||||
height: calc(100% + 6px);
|
||||
border-radius: 1000px;
|
||||
pointer-events: none;
|
||||
-webkit-mask: radial-gradient(farthest-side,
|
||||
transparent calc(100% - 5px),
|
||||
#000 calc(100% - 5px));
|
||||
mask: radial-gradient(farthest-side,
|
||||
transparent calc(100% - 5px),
|
||||
#000 calc(100% - 5px));
|
||||
}
|
||||
}
|
||||
|
||||
.announcement {
|
||||
top: 25%;
|
||||
transform: translateX(calc(-50% + 50vw)) translateY(-50%);
|
||||
|
|
@ -242,20 +333,65 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
.tutorial-hint {
|
||||
top: auto;
|
||||
bottom: calc(#{$medium-padding} + #{$large-icon} * 1.6 + #{$medium-padding});
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
|
||||
font-size: 1.6rem;
|
||||
@include background;
|
||||
z-index: 1000;
|
||||
padding: $small-padding $medium-padding;
|
||||
border-radius: 1000px;
|
||||
opacity: 0.9;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.planet-progress {
|
||||
top: $small-padding;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 50%;
|
||||
display: flex;
|
||||
$height: 8px;
|
||||
$height: 20px;
|
||||
height: $height;
|
||||
|
||||
justify-content: space-between;
|
||||
box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.2);
|
||||
|
||||
border-radius: 4px;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.35);
|
||||
box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.4);
|
||||
border-radius: 1000px;
|
||||
|
||||
.fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: $height;
|
||||
transition: width $animation-time;
|
||||
}
|
||||
|
||||
.fill.decla {
|
||||
right: 50%;
|
||||
background: $bright-decla;
|
||||
color: $bright-decla;
|
||||
border-radius: 1000px 0 0 1000px;
|
||||
}
|
||||
|
||||
.fill.red {
|
||||
left: 50%;
|
||||
background: $bright-red;
|
||||
color: $bright-red;
|
||||
border-radius: 0 1000px 1000px 0;
|
||||
}
|
||||
|
||||
.fill.match-point {
|
||||
z-index: 1;
|
||||
animation: match-point-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
|
|
@ -263,38 +399,87 @@ body {
|
|||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
background-color: #888;
|
||||
height: 24px;
|
||||
width: 4px;
|
||||
background-color: #fff;
|
||||
height: $height + 8px;
|
||||
width: 3px;
|
||||
border-radius: 1000px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.score {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 3;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
text-shadow:
|
||||
0 0 3px rgba(0, 0, 0, 0.95),
|
||||
0 0 6px rgba(0, 0, 0, 0.8);
|
||||
opacity: 0.8;
|
||||
transition:
|
||||
opacity $animation-time,
|
||||
text-shadow $animation-time,
|
||||
font-size $animation-time;
|
||||
}
|
||||
|
||||
.score.decla {
|
||||
right: calc(50% + #{$small-padding});
|
||||
}
|
||||
|
||||
.score.red {
|
||||
left: calc(50% + #{$small-padding});
|
||||
}
|
||||
|
||||
.score.leading {
|
||||
opacity: 1;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.score.decla.leading {
|
||||
text-shadow:
|
||||
0 0 3px rgba(0, 0, 0, 0.95),
|
||||
0 0 8px $bright-decla;
|
||||
}
|
||||
|
||||
.score.red.leading {
|
||||
text-shadow:
|
||||
0 0 3px rgba(0, 0, 0, 0.95),
|
||||
0 0 8px $bright-red;
|
||||
}
|
||||
|
||||
.you-marker {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
margin-top: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 0 4px rgba(0, 0, 0, 0.9);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translateX(-50%) translateY(50%);
|
||||
|
||||
@include square(24px);
|
||||
|
||||
background-image: url('../static/flag.svg');
|
||||
background-size: contain;
|
||||
bottom: 100%;
|
||||
transform: translateX(-50%);
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-bottom: 5px solid currentColor;
|
||||
}
|
||||
|
||||
div {
|
||||
height: $height;
|
||||
box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.2);
|
||||
&.decla {
|
||||
right: calc(50% + #{$small-padding});
|
||||
color: $bright-decla;
|
||||
}
|
||||
|
||||
div:nth-child(1) {
|
||||
background: $bright-decla;
|
||||
border-radius: 100px 0 0 100px;
|
||||
&.red {
|
||||
left: calc(50% + #{$small-padding});
|
||||
color: $bright-red;
|
||||
}
|
||||
|
||||
div:nth-child(2) {
|
||||
background: $bright-red;
|
||||
border-radius: 0 100px 100px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -308,6 +493,7 @@ body {
|
|||
background-color: transparent;
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $accent;
|
||||
border-radius: $border-radius;
|
||||
|
|
@ -321,6 +507,10 @@ body {
|
|||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
box-sizing: content-box;
|
||||
|
||||
user-select: none;
|
||||
|
|
@ -328,6 +518,7 @@ body {
|
|||
|
||||
padding: $medium-padding;
|
||||
@include square($large-icon);
|
||||
|
||||
@media (max-width: $breakpoint) {
|
||||
@include square($small-icon);
|
||||
padding: $small-padding;
|
||||
|
|
@ -338,3 +529,217 @@ body {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-hud {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
z-index: 2000;
|
||||
overflow: hidden;
|
||||
|
||||
.hitmarker {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
@include square(26px);
|
||||
opacity: 0.9;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 12px;
|
||||
height: 2px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
&::before {
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
&::after {
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
}
|
||||
|
||||
animation: hitmarker-pop 250ms ease-out forwards;
|
||||
}
|
||||
|
||||
.kill-popup {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
font-weight: 700;
|
||||
font-size: 1.4rem;
|
||||
white-space: nowrap;
|
||||
color: $bright-neutral;
|
||||
text-shadow: 0 0 6px rgba(0, 0, 0, 0.9);
|
||||
animation: kill-popup-rise 1200ms ease-out forwards;
|
||||
|
||||
.heal {
|
||||
color: #6fcf6f;
|
||||
}
|
||||
}
|
||||
|
||||
.streak-callout {
|
||||
position: absolute;
|
||||
top: 33%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-weight: 700;
|
||||
font-size: 2.6rem;
|
||||
color: $accent;
|
||||
text-shadow: 0 0 12px rgba(0, 0, 0, 0.9);
|
||||
white-space: nowrap;
|
||||
animation: streak-pop 1400ms ease-out forwards;
|
||||
}
|
||||
|
||||
.killfeed {
|
||||
position: absolute;
|
||||
top: calc(#{$small-padding} + 36px);
|
||||
right: $medium-padding;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
|
||||
.kill-entry {
|
||||
font-size: 1rem;
|
||||
padding: 4px 10px;
|
||||
border-radius: 1000px;
|
||||
@include background;
|
||||
text-shadow: 0 0 4px rgba(0, 0, 0, 0.9);
|
||||
animation: kill-entry-fade 4500ms ease-out forwards;
|
||||
|
||||
b {
|
||||
color: $accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.charge-ring {
|
||||
position: fixed;
|
||||
@include square(56px);
|
||||
margin: -28px 0 0 -28px;
|
||||
border-radius: 1000px;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
z-index: 2000;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms;
|
||||
-webkit-mask: radial-gradient(farthest-side,
|
||||
transparent calc(100% - 6px),
|
||||
#000 calc(100% - 6px));
|
||||
mask: radial-gradient(farthest-side,
|
||||
transparent calc(100% - 6px),
|
||||
#000 calc(100% - 6px));
|
||||
|
||||
&.full {
|
||||
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.8));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes match-point-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 4px 0 currentColor;
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 14px 3px currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes falling-point {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, calc(-50% - 90px));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hitmarker-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(1.6);
|
||||
}
|
||||
|
||||
25% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes kill-popup-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -30%) scale(0.8);
|
||||
}
|
||||
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -60%) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -160%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes streak-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.6);
|
||||
}
|
||||
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) scale(1.1);
|
||||
}
|
||||
|
||||
70% {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes kill-entry-fade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
10% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
80% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
58
frontend/src/scripts/charge-indicator.ts
Normal file
58
frontend/src/scripts/charge-indicator.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { holdDurationToCharge } from 'shared';
|
||||
import { Pointer } from './helper/pointer';
|
||||
|
||||
|
||||
export abstract class ChargeIndicator {
|
||||
private static element?: HTMLElement;
|
||||
private static heldSince = 0;
|
||||
private static raf = 0;
|
||||
private static followPointer = false;
|
||||
|
||||
public static begin(x: number, y: number, followPointer = false) {
|
||||
this.end();
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.className = 'charge-ring';
|
||||
element.style.left = `${x}px`;
|
||||
element.style.top = `${y}px`;
|
||||
document.body.appendChild(element);
|
||||
|
||||
this.element = element;
|
||||
this.heldSince = performance.now();
|
||||
this.followPointer = followPointer;
|
||||
this.raf = requestAnimationFrame(this.update);
|
||||
}
|
||||
|
||||
public static end() {
|
||||
if (this.element) {
|
||||
cancelAnimationFrame(this.raf);
|
||||
this.element.parentElement?.removeChild(this.element);
|
||||
this.element = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private static update = () => {
|
||||
const element = ChargeIndicator.element;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const charge = holdDurationToCharge(
|
||||
(performance.now() - ChargeIndicator.heldSince) / 1000,
|
||||
);
|
||||
element.style.opacity = charge < 0.12 ? '0' : '1';
|
||||
element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${charge * 360
|
||||
}deg, rgba(255, 255, 255, 0.15) 0deg)`;
|
||||
element.classList.toggle('full', charge >= 1);
|
||||
|
||||
if (ChargeIndicator.followPointer) {
|
||||
const cursor = Pointer.getDisplayPosition();
|
||||
if (cursor) {
|
||||
element.style.left = `${cursor.x}px`;
|
||||
element.style.top = `${cursor.y}px`;
|
||||
}
|
||||
}
|
||||
|
||||
ChargeIndicator.raf = requestAnimationFrame(ChargeIndicator.update);
|
||||
};
|
||||
}
|
||||
|
|
@ -1,8 +1,14 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { CommandGenerator, PrimaryActionCommand, SecondaryActionCommand } from 'shared';
|
||||
import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared';
|
||||
import { Game } from '../game';
|
||||
import { ChargeIndicator } from '../charge-indicator';
|
||||
import { Pointer } from '../helper/pointer';
|
||||
|
||||
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(
|
||||
private target: HTMLElement,
|
||||
private readonly game: Game,
|
||||
|
|
@ -10,22 +16,43 @@ export class MouseListener extends CommandGenerator {
|
|||
super();
|
||||
|
||||
target.addEventListener('mousedown', this.mouseDownListener);
|
||||
target.addEventListener('mouseup', this.mouseUpListener);
|
||||
target.addEventListener('mousemove', this.mouseMoveListener);
|
||||
target.addEventListener('contextmenu', this.contextMenuListener);
|
||||
}
|
||||
|
||||
private mouseDownListener = (event: MouseEvent) => {
|
||||
const position = this.positionFromEvent(event);
|
||||
// Only the screen position is stored; it is reprojected to world space each
|
||||
// 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) {
|
||||
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) => {
|
||||
event.preventDefault();
|
||||
|
||||
const position = this.positionFromEvent(event);
|
||||
this.sendCommandToSubscribers(new SecondaryActionCommand(position));
|
||||
};
|
||||
|
||||
private positionFromEvent(event: MouseEvent): vec2 {
|
||||
|
|
@ -35,7 +62,10 @@ export class MouseListener extends CommandGenerator {
|
|||
}
|
||||
|
||||
public destroy() {
|
||||
ChargeIndicator.end();
|
||||
this.target.removeEventListener('mousedown', this.mouseDownListener);
|
||||
this.target.removeEventListener('mouseup', this.mouseUpListener);
|
||||
this.target.removeEventListener('mousemove', this.mouseMoveListener);
|
||||
this.target.removeEventListener('contextmenu', this.contextMenuListener);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
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 { ChargeIndicator } from '../charge-indicator';
|
||||
|
||||
export class TouchListener extends CommandGenerator {
|
||||
private static readonly deadZone = 8;
|
||||
|
|
@ -10,6 +18,12 @@ export class TouchListener extends CommandGenerator {
|
|||
private joystickButton: HTMLElement;
|
||||
private isJoystickActive = false;
|
||||
private touchStartPosition!: vec2;
|
||||
private primaryDownAt: number | null = null;
|
||||
|
||||
private fireButton: HTMLElement;
|
||||
private fireStrengthRing: HTMLElement;
|
||||
|
||||
private fireDownAt: number | null = null;
|
||||
|
||||
constructor(
|
||||
private target: HTMLElement,
|
||||
|
|
@ -23,6 +37,17 @@ export class TouchListener extends CommandGenerator {
|
|||
this.joystickButton = document.createElement('div');
|
||||
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('touchmove', this.touchMoveListener);
|
||||
target.addEventListener('touchend', this.touchEndListener);
|
||||
|
|
@ -43,6 +68,8 @@ export class TouchListener extends CommandGenerator {
|
|||
event.touches[0].clientX,
|
||||
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) {
|
||||
this.isJoystickActive = true;
|
||||
this.primaryDownAt = null;
|
||||
ChargeIndicator.end();
|
||||
this.overlay.appendChild(this.joystick);
|
||||
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%)`;
|
||||
|
|
@ -71,7 +100,8 @@ export class TouchListener extends CommandGenerator {
|
|||
|
||||
vec2.set(delta, delta.x, -delta.y);
|
||||
if (deltaLength > TouchListener.deadZone) {
|
||||
this.sendCommandToSubscribers(new MoveActionCommand(vec2.normalize(delta, delta)));
|
||||
const direction = vec2.normalize(delta, delta);
|
||||
this.sendCommandToSubscribers(new MoveActionCommand(direction));
|
||||
} else {
|
||||
this.sendCommandToSubscribers(new MoveActionCommand(vec2.create()));
|
||||
}
|
||||
|
|
@ -81,12 +111,18 @@ export class TouchListener extends CommandGenerator {
|
|||
event.preventDefault();
|
||||
|
||||
if (!this.isJoystickActive) {
|
||||
ChargeIndicator.end();
|
||||
const charge =
|
||||
this.primaryDownAt === null
|
||||
? 0
|
||||
: holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
|
||||
this.primaryDownAt = null;
|
||||
const center = vec2.fromValues(
|
||||
event.changedTouches[0].clientX,
|
||||
event.changedTouches[0].clientY,
|
||||
);
|
||||
this.sendCommandToSubscribers(
|
||||
new PrimaryActionCommand(this.game.displayToWorldCoordinates(center)),
|
||||
new PrimaryActionCommand(this.game.displayToWorldCoordinates(center), charge),
|
||||
);
|
||||
} else if (event.touches.length === 0) {
|
||||
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() {
|
||||
ChargeIndicator.end();
|
||||
this.target.removeEventListener('touchstart', this.touchStartListener);
|
||||
this.target.removeEventListener('touchmove', this.touchMoveListener);
|
||||
this.target.removeEventListener('touchend', this.touchEndListener);
|
||||
|
||||
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
|
||||
this.fireButton.removeEventListener('touchend', this.fireButtonUpListener);
|
||||
|
||||
this.fireButton.parentElement?.removeChild(this.fireButton);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
89
frontend/src/scripts/feedback-hud.ts
Normal file
89
frontend/src/scripts/feedback-hud.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { settings } from 'shared';
|
||||
import { Pointer } from './helper/pointer';
|
||||
|
||||
export abstract class FeedbackHud {
|
||||
private static root?: HTMLElement;
|
||||
private static killfeed?: HTMLElement;
|
||||
|
||||
private static ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } {
|
||||
if (!this.root || !this.killfeed) {
|
||||
this.root = document.createElement('div');
|
||||
this.root.className = 'feedback-hud';
|
||||
|
||||
this.killfeed = document.createElement('div');
|
||||
this.killfeed.className = 'killfeed';
|
||||
this.root.appendChild(this.killfeed);
|
||||
|
||||
document.body.appendChild(this.root);
|
||||
}
|
||||
return { root: this.root, killfeed: this.killfeed };
|
||||
}
|
||||
|
||||
private static focusPoint(): { x: number; y: number } {
|
||||
const cursor = Pointer.getDisplayPosition();
|
||||
if (cursor) {
|
||||
return { x: cursor.x, y: cursor.y };
|
||||
}
|
||||
return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
||||
}
|
||||
|
||||
private static addTransient(element: HTMLElement, lifetimeMs: number) {
|
||||
const { root } = this.ensureRoot();
|
||||
root.appendChild(element);
|
||||
setTimeout(() => element.parentElement?.removeChild(element), lifetimeMs);
|
||||
}
|
||||
|
||||
public static hitMarker() {
|
||||
const { x, y } = this.focusPoint();
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'hitmarker';
|
||||
marker.style.left = `${x}px`;
|
||||
marker.style.top = `${y}px`;
|
||||
this.addTransient(marker, 250);
|
||||
}
|
||||
|
||||
public static killConfirmed(victimName?: string, streak = 1) {
|
||||
const { killfeed } = this.ensureRoot();
|
||||
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'kill-entry';
|
||||
entry.innerHTML = `Eliminated <b>${this.escape(victimName ?? 'enemy')}</b>`;
|
||||
killfeed.insertBefore(entry, killfeed.firstChild);
|
||||
setTimeout(() => entry.parentElement?.removeChild(entry), 4500);
|
||||
|
||||
const { x, y } = this.focusPoint();
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'kill-popup';
|
||||
popup.innerHTML = `+${settings.playerKillPoint} <span class="heal">+${settings.playerKillHealthReward}❤</span>`;
|
||||
popup.style.left = `${x}px`;
|
||||
popup.style.top = `${y}px`;
|
||||
this.addTransient(popup, 1200);
|
||||
|
||||
const callout = this.streakName(streak);
|
||||
if (callout) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'streak-callout';
|
||||
el.innerText = callout;
|
||||
this.addTransient(el, 1400);
|
||||
}
|
||||
}
|
||||
|
||||
private static streakName(streak: number): string | undefined {
|
||||
switch (streak) {
|
||||
case 2:
|
||||
return 'Double Kill!';
|
||||
case 3:
|
||||
return 'Triple Kill!';
|
||||
case 4:
|
||||
return 'Quad Kill!';
|
||||
default:
|
||||
return streak >= 5 ? 'Rampage!' : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private static escape(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.innerText = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
|
@ -30,10 +30,12 @@ import { CommandSocket } from './commands/command-socket';
|
|||
import { PlayerDecision } from './join-form-handler';
|
||||
import { GameObjectContainer } from './objects/game-object-container';
|
||||
import parser from 'socket.io-msgpack-parser';
|
||||
import { BlobShape } from './shapes/blob-shape';
|
||||
import { CharacterShape } from './shapes/character-shape';
|
||||
import { PlanetShape } from './shapes/planet-shape';
|
||||
import { RenderCommand } from './commands/types/render';
|
||||
import { StepCommand } from './commands/types/step';
|
||||
import { Tutorial } from './tutorial';
|
||||
import { Scoreboard } from './scoreboard';
|
||||
|
||||
export class Game extends CommandReceiver {
|
||||
public gameObjects = new GameObjectContainer(this);
|
||||
|
|
@ -48,12 +50,11 @@ export class Game extends CommandReceiver {
|
|||
private mouseListener: MouseListener;
|
||||
private touchListener: TouchListener;
|
||||
|
||||
private declaPlanetCountElement = document.createElement('div');
|
||||
private redPlanetCountElement = document.createElement('div');
|
||||
private scoreboard = new Scoreboard();
|
||||
private announcementText = document.createElement('h2');
|
||||
private progressBar = document.createElement('div');
|
||||
private arrows: { [id: number]: HTMLElement } = {};
|
||||
private socketReceiver!: CommandSocket;
|
||||
private tutorial!: Tutorial;
|
||||
|
||||
constructor(
|
||||
private readonly playerDecision: PlayerDecision,
|
||||
|
|
@ -63,9 +64,6 @@ export class Game extends CommandReceiver {
|
|||
super();
|
||||
this.started = new Promise((r) => (this.resolveStarted = r));
|
||||
this.announcementText.className = 'announcement';
|
||||
this.progressBar.className = 'planet-progress';
|
||||
this.progressBar.appendChild(this.declaPlanetCountElement);
|
||||
this.progressBar.appendChild(this.redPlanetCountElement);
|
||||
|
||||
this.keyboardListener = new KeyboardListener();
|
||||
this.mouseListener = new MouseListener(this.canvas, this);
|
||||
|
|
@ -78,12 +76,14 @@ export class Game extends CommandReceiver {
|
|||
this.socket?.close();
|
||||
this.gameObjects = new GameObjectContainer(this);
|
||||
this.overlay.innerHTML = '';
|
||||
this.arrows = {};
|
||||
this.isEnding = false;
|
||||
this.lastAnnouncementText = '';
|
||||
this.overlay.appendChild(this.progressBar);
|
||||
this.overlay.appendChild(this.scoreboard.element);
|
||||
this.announcementText.innerText = '';
|
||||
this.timeScaling = 1;
|
||||
this.overlay.appendChild(this.announcementText);
|
||||
this.tutorial = new Tutorial(this.overlay);
|
||||
|
||||
this.socket = io(this.playerDecision.server, {
|
||||
reconnectionDelayMax: 10000,
|
||||
|
|
@ -114,12 +114,17 @@ export class Game extends CommandReceiver {
|
|||
});
|
||||
|
||||
this.socketReceiver = new CommandSocket(this.socket);
|
||||
// The tutorial listens to the same input streams as the socket, so its
|
||||
// stages clear off the player's own commands without any server involvement.
|
||||
this.keyboardListener.clearSubscribers();
|
||||
this.keyboardListener.subscribe(this.socketReceiver);
|
||||
this.keyboardListener.subscribe(this.tutorial);
|
||||
this.mouseListener.clearSubscribers();
|
||||
this.mouseListener.subscribe(this.socketReceiver);
|
||||
this.mouseListener.subscribe(this.tutorial);
|
||||
this.touchListener.clearSubscribers();
|
||||
this.touchListener.subscribe(this.socketReceiver);
|
||||
this.touchListener.subscribe(this.tutorial);
|
||||
|
||||
this.isBetweenGames = false;
|
||||
|
||||
|
|
@ -189,8 +194,7 @@ export class Game extends CommandReceiver {
|
|||
clamp(p.x, arrowPadding, width - arrowPadding),
|
||||
clamp(height - p.y, arrowPadding, height - arrowPadding),
|
||||
);
|
||||
e.style.transform = `translateX(${p.x}px) translateY(${
|
||||
p.y
|
||||
e.style.transform = `translateX(${p.x}px) translateY(${p.y
|
||||
}px) translateX(-50%) translateY(-50%) rotate(${-angle + Math.PI / 2}rad) `;
|
||||
});
|
||||
|
||||
|
|
@ -214,7 +218,7 @@ export class Game extends CommandReceiver {
|
|||
this.canvas,
|
||||
[
|
||||
PlanetShape.descriptor,
|
||||
BlobShape.descriptor,
|
||||
CharacterShape.descriptor,
|
||||
{
|
||||
...CircleLight.descriptor,
|
||||
shaderCombinationSteps: [0, 1, 2, 4, 8, 16],
|
||||
|
|
@ -223,10 +227,11 @@ export class Game extends CommandReceiver {
|
|||
this.gameLoop.bind(this),
|
||||
{
|
||||
shadowTraceCount: 16,
|
||||
paletteSize: settings.palette.length,
|
||||
colorPalette: settings.palette,
|
||||
paletteSize: settings.paletteDim.length,
|
||||
colorPalette: settings.paletteDim,
|
||||
enableHighDpiRendering: true,
|
||||
lightCutoffDistance: settings.lightCutoffDistance,
|
||||
lightOverlapReduction: settings.lightOverlapReduction,
|
||||
textures: {
|
||||
noiseTexture: {
|
||||
source: noiseTexture,
|
||||
|
|
@ -276,7 +281,9 @@ export class Game extends CommandReceiver {
|
|||
this.draw();
|
||||
}
|
||||
|
||||
if ((this.timeSinceLastAnnouncement += deltaTime) > 0.5) {
|
||||
if (
|
||||
(this.timeSinceLastAnnouncement += deltaTime) > settings.announcementVisibleSeconds
|
||||
) {
|
||||
this.lastAnnouncementText = '';
|
||||
}
|
||||
|
||||
|
|
@ -292,6 +299,10 @@ export class Game extends CommandReceiver {
|
|||
new RenderCommand(this.renderer, this.overlay, shouldChangeLayout),
|
||||
);
|
||||
|
||||
this.touchListener.update(deltaTime);
|
||||
|
||||
this.tutorial.step(this.gameObjects);
|
||||
|
||||
this.socketReceiver.sendQueuedCommands();
|
||||
|
||||
return this.isActive;
|
||||
|
|
@ -299,10 +310,8 @@ export class Game extends CommandReceiver {
|
|||
|
||||
private draw() {
|
||||
if (this.lastGameState) {
|
||||
this.declaPlanetCountElement.style.width =
|
||||
(this.lastGameState.declaCount / this.lastGameState.limit) * 50 + '%';
|
||||
this.redPlanetCountElement.style.width =
|
||||
(this.lastGameState.redCount / this.lastGameState.limit) * 50 + '%';
|
||||
// The local player's team is read off the main character once it exists.
|
||||
this.scoreboard.update(this.lastGameState, this.gameObjects.player?.team);
|
||||
}
|
||||
|
||||
if (this.lastOtherPlayerDirections) {
|
||||
|
|
|
|||
13
frontend/src/scripts/helper/pointer.ts
Normal file
13
frontend/src/scripts/helper/pointer.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
export class Pointer {
|
||||
private static displayPosition: vec2 | null = null;
|
||||
|
||||
public static setDisplayPosition(x: number, y: number): void {
|
||||
Pointer.displayPosition = vec2.fromValues(x, y);
|
||||
}
|
||||
|
||||
public static getDisplayPosition(): vec2 | null {
|
||||
return Pointer.displayPosition;
|
||||
}
|
||||
}
|
||||
|
|
@ -102,7 +102,7 @@ export class JoinFormHandler {
|
|||
|
||||
private removeServer(server: ServerChooserOption) {
|
||||
this.servers = this.servers.filter((s) => s !== server);
|
||||
if (this.servers.length) {
|
||||
if (!this.servers.length) {
|
||||
this.joinButton.disabled = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -143,16 +143,17 @@ class ServerChooserOption {
|
|||
this.setServerInfoLabelText();
|
||||
|
||||
this.socket = io(url, {
|
||||
reconnection: false,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
timeout: 4000,
|
||||
parser,
|
||||
} as any);
|
||||
|
||||
// `connect_timeout` was removed in socket.io-client v3+; connection
|
||||
// timeouts now surface through `connect_error` (reconnection is disabled).
|
||||
this.socket.on('connect_error', this.destroy.bind(this));
|
||||
this.socket.on('disconnect', this.destroy.bind(this));
|
||||
this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates);
|
||||
this.socket.io.on('reconnect_failed', this.destroy.bind(this));
|
||||
|
||||
this.socket.on('connect', () =>
|
||||
this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates),
|
||||
);
|
||||
this.socket.on(
|
||||
TransportEvents.ServerInfoUpdate,
|
||||
([playerCount, gameState]: [number, number]) => {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { StepCommand } from '../commands/types/step';
|
|||
import { Game } from '../game';
|
||||
import { Camera } from './types/camera';
|
||||
import { CharacterView } from './types/character-view';
|
||||
import { PlanetView } from './types/planet-view';
|
||||
|
||||
export class GameObjectContainer extends CommandReceiver {
|
||||
protected objects: Map<Id, GameObject> = new Map();
|
||||
|
|
@ -57,6 +58,16 @@ export class GameObjectContainer extends CommandReceiver {
|
|||
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) {
|
||||
this.objects.forEach((o) => o.handleCommand(c));
|
||||
this.camera.handleCommand(c);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { CircleLight, Renderer } from 'sdf-2d';
|
||||
|
||||
import {
|
||||
Circle,
|
||||
|
|
@ -6,6 +7,9 @@ import {
|
|||
CharacterBase,
|
||||
CharacterTeam,
|
||||
settings,
|
||||
clamp,
|
||||
clamp01,
|
||||
mix,
|
||||
CommandExecutors,
|
||||
UpdatePropertyCommand,
|
||||
} from 'shared';
|
||||
|
|
@ -13,15 +17,36 @@ import { BeforeDestroyCommand } from '../../commands/types/before-destroy';
|
|||
import { RenderCommand } from '../../commands/types/render';
|
||||
import { StepCommand } from '../../commands/types/step';
|
||||
import { CircleExtrapolator } from '../../helper/extrapolators/circle-extrapolator';
|
||||
import { BlobShape } from '../../shapes/blob-shape';
|
||||
import { LinearExtrapolator } from '../../helper/extrapolators/linear-extrapolator';
|
||||
import { Pointer } from '../../helper/pointer';
|
||||
import { CharacterShape } from '../../shapes/character-shape';
|
||||
import { SoundHandler, Sounds } from '../../sound-handler';
|
||||
import { VibrationHandler } from '../../vibration-handler';
|
||||
import { FeedbackHud } from '../../feedback-hud';
|
||||
|
||||
const muzzleFlashDecaySeconds = 0.12;
|
||||
const hitFlashDecaySeconds = 0.15;
|
||||
|
||||
const killIcon =
|
||||
'<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
|
||||
'<path d="M6.2,2.44L18.1,14.34L20.22,12.22L21.63,13.63L19.16,16.1L22.34,19.28C22.73,19.67 22.73,20.3 22.34,20.69L21.63,21.4C21.24,21.79 20.61,21.79 20.22,21.4L17,18.23L14.56,20.7L13.15,19.29L15.27,17.17L3.37,5.27V2.44H6.2M15.89,10L20.63,5.26V2.44H17.8L13.06,7.18L15.89,10M10.94,15L8.11,12.13L5.9,14.34L3.78,12.22L2.37,13.63L4.84,16.1L1.66,19.28C1.27,19.67 1.27,20.3 1.66,20.69L2.37,21.4C2.76,21.79 3.39,21.79 3.78,21.4L7,18.23L9.42,20.7L10.83,19.29L8.71,17.17L10.94,15Z"/></svg>';
|
||||
const deathIcon =
|
||||
'<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
|
||||
'<path d="M12,2A9,9 0 0,0 3,11C3,14.03 4.53,16.82 7,18.47V22H9V19H11V22H13V19H15V18.46C17.47,16.81 19,14.03 19,11A9,9 0 0,0 12,2M8,11A2,2 0 0,1 10,13A2,2 0 0,1 8,15A2,2 0 0,1 6,13A2,2 0 0,1 8,11M16,11A2,2 0 0,1 18,13A2,2 0 0,1 16,15A2,2 0 0,1 14,13A2,2 0 0,1 16,11Z"/></svg>';
|
||||
|
||||
export class CharacterView extends CharacterBase {
|
||||
private shape: BlobShape;
|
||||
private shape: CharacterShape;
|
||||
private muzzleFlash: CircleLight;
|
||||
private muzzleFlashIntensity = 0;
|
||||
private hitFlashIntensity = 0;
|
||||
private strength = settings.playerMaxStrength;
|
||||
private strengthExtrapolator = new LinearExtrapolator(settings.playerMaxStrength);
|
||||
private nameElement: HTMLElement = document.createElement('div');
|
||||
private statsElement: HTMLElement = document.createElement('div');
|
||||
private killCountElement: HTMLElement = document.createElement('span');
|
||||
private deathCountElement: HTMLElement = document.createElement('span');
|
||||
private healthElement: HTMLElement = document.createElement('div');
|
||||
private chargeElement: HTMLElement = document.createElement('div');
|
||||
|
||||
public isMainCharacter = false;
|
||||
|
||||
|
|
@ -48,7 +73,12 @@ export class CharacterView extends CharacterBase {
|
|||
rightFoot?: Circle,
|
||||
) {
|
||||
super(id, name, killCount, deathCount, team, health, head, leftFoot, rightFoot);
|
||||
this.shape = new BlobShape(settings.colorIndices[team]);
|
||||
this.shape = new CharacterShape(settings.colorIndices[team]);
|
||||
this.muzzleFlash = new CircleLight(
|
||||
vec2.clone(this.head!.center),
|
||||
settings.paletteDim[settings.colorIndices[team]],
|
||||
0,
|
||||
);
|
||||
|
||||
this.leftFootExtrapolator = new CircleExtrapolator(this.leftFoot!);
|
||||
this.rightFootExtrapolator = new CircleExtrapolator(this.rightFoot!);
|
||||
|
|
@ -56,7 +86,24 @@ export class CharacterView extends CharacterBase {
|
|||
|
||||
this.nameElement.className = 'player-tag ' + this.team;
|
||||
this.nameElement.innerText = this.name;
|
||||
this.healthElement.className = 'health';
|
||||
this.chargeElement.className = 'charge';
|
||||
|
||||
this.statsElement.className = 'stats';
|
||||
this.killCountElement.className = 'value';
|
||||
this.deathCountElement.className = 'value';
|
||||
const killStat = document.createElement('span');
|
||||
killStat.className = 'stat kills';
|
||||
killStat.innerHTML = killIcon;
|
||||
killStat.appendChild(this.killCountElement);
|
||||
const deathStat = document.createElement('span');
|
||||
deathStat.className = 'stat deaths';
|
||||
deathStat.innerHTML = deathIcon;
|
||||
deathStat.appendChild(this.deathCountElement);
|
||||
this.statsElement.append(killStat, deathStat);
|
||||
|
||||
this.nameElement.appendChild(this.healthElement);
|
||||
this.nameElement.appendChild(this.chargeElement);
|
||||
this.nameElement.appendChild(this.statsElement);
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +111,29 @@ export class CharacterView extends CharacterBase {
|
|||
return this.head!.center;
|
||||
}
|
||||
|
||||
public get bodyCenter(): vec2 {
|
||||
const center = vec2.add(vec2.create(), this.head!.center, this.leftFoot!.center);
|
||||
vec2.add(center, center, this.rightFoot!.center);
|
||||
return vec2.scale(center, center, 1 / 3);
|
||||
}
|
||||
|
||||
public get facingDirection(): vec2 {
|
||||
const footAverage = vec2.add(
|
||||
vec2.create(),
|
||||
this.leftFoot!.center,
|
||||
this.rightFoot!.center,
|
||||
);
|
||||
vec2.scale(footAverage, footAverage, 0.5);
|
||||
const forward = vec2.subtract(footAverage, this.head!.center, footAverage);
|
||||
return vec2.length(forward) > 0
|
||||
? vec2.normalize(forward, forward)
|
||||
: vec2.fromValues(0, 1);
|
||||
}
|
||||
|
||||
public get strengthFraction(): number {
|
||||
return clamp01(this.strength / settings.playerMaxStrength);
|
||||
}
|
||||
|
||||
private updateProperty({
|
||||
propertyKey,
|
||||
propertyValue,
|
||||
|
|
@ -78,18 +148,25 @@ export class CharacterView extends CharacterBase {
|
|||
if (propertyKey === 'rightFoot') {
|
||||
this.rightFootExtrapolator.addFrame(propertyValue, rateOfChange);
|
||||
}
|
||||
if (propertyKey === 'strength') {
|
||||
this.strengthExtrapolator.addFrame(propertyValue, rateOfChange);
|
||||
}
|
||||
}
|
||||
|
||||
public setHealth(health: number) {
|
||||
const previousHealth = this.health;
|
||||
const damage = this.health - health;
|
||||
super.setHealth(health);
|
||||
|
||||
if (damage > 0) {
|
||||
SoundHandler.play(
|
||||
Sounds.hit,
|
||||
(0.4 * 2 * (previousHealth - health)) / settings.playerMaxStrength,
|
||||
Math.min(1, (0.8 * damage) / settings.playerMaxStrength),
|
||||
);
|
||||
this.hitFlashIntensity = Math.min(1, 0.4 + damage / settings.playerMaxStrength);
|
||||
|
||||
if (this.isMainCharacter) {
|
||||
VibrationHandler.vibrate(Math.min(200, (previousHealth - this.health) * 4));
|
||||
VibrationHandler.vibrate(Math.min(200, damage * 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -99,14 +176,58 @@ export class CharacterView extends CharacterBase {
|
|||
}
|
||||
}
|
||||
|
||||
public onHitConfirmed() {
|
||||
if (!this.isMainCharacter) {
|
||||
return;
|
||||
}
|
||||
SoundHandler.play(Sounds.click, 0.4, 1.7);
|
||||
FeedbackHud.hitMarker();
|
||||
}
|
||||
|
||||
public onKillConfirmed(victimName?: string, streak = 1) {
|
||||
if (!this.isMainCharacter) {
|
||||
return;
|
||||
}
|
||||
SoundHandler.play(Sounds.click, 1, 0.7);
|
||||
VibrationHandler.vibrate(60);
|
||||
FeedbackHud.killConfirmed(victimName, streak);
|
||||
}
|
||||
|
||||
private step({ deltaTimeInSeconds }: StepCommand): void {
|
||||
this.head! = this.headExtrapolator.getValue(deltaTimeInSeconds);
|
||||
this.leftFoot! = this.leftFootExtrapolator.getValue(deltaTimeInSeconds);
|
||||
this.rightFoot! = this.rightFootExtrapolator.getValue(deltaTimeInSeconds);
|
||||
|
||||
this.strength = clamp(
|
||||
this.strengthExtrapolator.getValue(deltaTimeInSeconds),
|
||||
0,
|
||||
settings.playerMaxStrength,
|
||||
);
|
||||
|
||||
if (this.muzzleFlashIntensity > 0) {
|
||||
this.muzzleFlashIntensity = Math.max(
|
||||
0,
|
||||
this.muzzleFlashIntensity - deltaTimeInSeconds / muzzleFlashDecaySeconds,
|
||||
);
|
||||
this.muzzleFlash.center = this.head!.center;
|
||||
this.muzzleFlash.intensity = this.muzzleFlashIntensity;
|
||||
}
|
||||
|
||||
if (this.hitFlashIntensity > 0) {
|
||||
this.hitFlashIntensity = Math.max(
|
||||
0,
|
||||
this.hitFlashIntensity - deltaTimeInSeconds / hitFlashDecaySeconds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public onShoot(strength: number) {
|
||||
SoundHandler.play(Sounds.shoot, (0.6 * strength) / settings.playerMaxStrength);
|
||||
const q = clamp01(
|
||||
(strength - settings.chargeShotStrengthMin) /
|
||||
(settings.chargeShotStrengthMax - settings.chargeShotStrengthMin),
|
||||
);
|
||||
SoundHandler.play(Sounds.shoot, mix(0.55, 1, q), mix(1.15, 0.8, q));
|
||||
this.muzzleFlashIntensity = mix(0.35, 1, q);
|
||||
}
|
||||
|
||||
private beforeDestroy(): void {
|
||||
|
|
@ -127,15 +248,34 @@ export class CharacterView extends CharacterBase {
|
|||
|
||||
this.healthElement.style.width =
|
||||
(50 * this.health) / settings.playerMaxHealth + 'px';
|
||||
this.statsElement.innerText = this.getStatsText();
|
||||
this.chargeElement.style.width =
|
||||
(50 * this.strength) / settings.playerMaxStrength + 'px';
|
||||
this.killCountElement.innerText = String(this.killCount);
|
||||
this.deathCountElement.innerText = String(this.deathCount);
|
||||
}
|
||||
|
||||
this.shape.hitFlash = this.hitFlashIntensity;
|
||||
this.shape.gazeTarget = this.calculateGazeTarget(renderer);
|
||||
this.shape.setCircles([this.head!, this.leftFoot!, this.rightFoot!]);
|
||||
renderer.addDrawable(this.shape);
|
||||
|
||||
if (this.muzzleFlashIntensity > 0) {
|
||||
renderer.addDrawable(this.muzzleFlash);
|
||||
}
|
||||
}
|
||||
|
||||
private getStatsText(): string {
|
||||
return `${this.killCount}⚔/${this.deathCount}☠`;
|
||||
private calculateGazeTarget(renderer: Renderer): vec2 {
|
||||
const cursor = Pointer.getDisplayPosition();
|
||||
if (this.isMainCharacter && cursor) {
|
||||
return renderer.displayToWorldCoordinates(cursor);
|
||||
}
|
||||
|
||||
return vec2.scaleAndAdd(
|
||||
vec2.create(),
|
||||
this.head!.center,
|
||||
this.facingDirection,
|
||||
this.head!.radius * 8,
|
||||
);
|
||||
}
|
||||
|
||||
private calculateTextPosition(): vec2 {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,36 @@
|
|||
import { vec2, vec3 } from 'gl-matrix';
|
||||
import { CircleLight } from 'sdf-2d';
|
||||
import { CommandExecutors, Id, LampBase } from 'shared';
|
||||
import { CommandExecutors, Id, LampBase, mixRgb, settings } from 'shared';
|
||||
import { RenderCommand } from '../../commands/types/render';
|
||||
import { StepCommand } from '../../commands/types/step';
|
||||
|
||||
export class LampView extends LampBase {
|
||||
private light: CircleLight;
|
||||
|
||||
private targetColor: vec3;
|
||||
private targetLightness: number;
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[RenderCommand.type]: this.draw.bind(this),
|
||||
[StepCommand.type]: this.step.bind(this),
|
||||
};
|
||||
|
||||
constructor(id: Id, center: vec2, color: vec3, lightness: number) {
|
||||
super(id, center, color, lightness);
|
||||
this.light = new CircleLight(center, color, lightness);
|
||||
this.light = new CircleLight(vec2.clone(center), vec3.clone(color), lightness);
|
||||
this.targetColor = vec3.clone(color);
|
||||
this.targetLightness = lightness;
|
||||
}
|
||||
|
||||
public setLight(color: vec3, lightness: number) {
|
||||
this.targetColor = vec3.clone(color);
|
||||
this.targetLightness = lightness;
|
||||
}
|
||||
|
||||
private step({ deltaTimeInSeconds }: StepCommand): void {
|
||||
const t = 1 - Math.exp(-deltaTimeInSeconds / settings.lampLerpSeconds);
|
||||
this.light.color = mixRgb(this.light.color, this.targetColor, t);
|
||||
this.light.intensity += (this.targetLightness - this.light.intensity) * t;
|
||||
}
|
||||
|
||||
private draw({ renderer }: RenderCommand): void {
|
||||
|
|
|
|||
|
|
@ -1,21 +1,49 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { Id, Random, PlanetBase, UpdatePropertyCommand, CommandExecutors } from 'shared';
|
||||
import { vec2, vec3 } from 'gl-matrix';
|
||||
import { CircleLight } from 'sdf-2d';
|
||||
import {
|
||||
Id,
|
||||
Random,
|
||||
PlanetBase,
|
||||
UpdatePropertyCommand,
|
||||
CommandExecutors,
|
||||
CharacterTeam,
|
||||
settings,
|
||||
} from 'shared';
|
||||
import { BeforeDestroyCommand } from '../../commands/types/before-destroy';
|
||||
import { RenderCommand } from '../../commands/types/render';
|
||||
import { StepCommand } from '../../commands/types/step';
|
||||
import { PlanetShape } from '../../shapes/planet-shape';
|
||||
|
||||
type FallingPoint = {
|
||||
velocity: vec2;
|
||||
position: vec2;
|
||||
element: HTMLElement;
|
||||
addedToOverlay: boolean;
|
||||
timeToLive: number;
|
||||
};
|
||||
const fallingPointLifetimeMs = 2000;
|
||||
|
||||
// Global budget for simultaneously-lit capture flares, so a clustered wave of
|
||||
// captures can never white out the SDF exposure — excess flips still pulse the
|
||||
// ring and toast, they just skip the extra light. The acquire/release pair keeps
|
||||
// the count in one place instead of being hand-maintained at every call site.
|
||||
abstract class FlareBudget {
|
||||
private static active = 0;
|
||||
|
||||
public static tryAcquire(): boolean {
|
||||
if (FlareBudget.active >= settings.maxConcurrentFlipFlares) {
|
||||
return false;
|
||||
}
|
||||
FlareBudget.active++;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static release(): void {
|
||||
FlareBudget.active = Math.max(0, FlareBudget.active - 1);
|
||||
}
|
||||
}
|
||||
|
||||
export class PlanetView extends PlanetBase {
|
||||
private shape: PlanetShape;
|
||||
private ownershipProgress: HTMLElement;
|
||||
private readonly rotationSpeed: number;
|
||||
|
||||
private flareLight?: CircleLight;
|
||||
private flareIntensity = 0;
|
||||
private holdsFlareSlot = false;
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[RenderCommand.type]: this.draw.bind(this),
|
||||
|
|
@ -27,45 +55,69 @@ export class PlanetView extends PlanetBase {
|
|||
constructor(id: Id, vertices: Array<vec2>, ownership: number) {
|
||||
super(id, vertices);
|
||||
this.shape = new PlanetShape(vertices, ownership);
|
||||
(this.shape as any).randomOffset = Random.getRandom();
|
||||
this.shape.randomOffset = Random.getRandom();
|
||||
this.rotationSpeed =
|
||||
(0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1);
|
||||
|
||||
this.ownershipProgress = document.createElement('div');
|
||||
this.ownershipProgress.className = 'ownership';
|
||||
}
|
||||
|
||||
private step({ deltaTimeInSeconds }: StepCommand): void {
|
||||
this.shape.randomOffset += deltaTimeInSeconds / 4;
|
||||
this.shape.rotation += deltaTimeInSeconds * this.rotationSpeed;
|
||||
this.shape.colorMixQ = this.ownership;
|
||||
|
||||
this.generatedPointElements.forEach((p) => {
|
||||
vec2.add(
|
||||
p.velocity,
|
||||
p.velocity,
|
||||
vec2.scale(vec2.create(), vec2.fromValues(0, 50), deltaTimeInSeconds),
|
||||
if (this.flareIntensity > 0) {
|
||||
this.flareIntensity = Math.max(
|
||||
0,
|
||||
this.flareIntensity - deltaTimeInSeconds / settings.lampFlareDecaySeconds,
|
||||
);
|
||||
|
||||
vec2.add(
|
||||
p.position,
|
||||
p.position,
|
||||
vec2.scale(vec2.create(), p.velocity, deltaTimeInSeconds),
|
||||
);
|
||||
|
||||
p.timeToLive -= deltaTimeInSeconds;
|
||||
});
|
||||
if (this.flareLight) {
|
||||
this.flareLight.intensity =
|
||||
settings.lampFlareIntensity * this.flareIntensity * this.flareIntensity;
|
||||
}
|
||||
|
||||
private generatedPointElements: Array<FallingPoint> = [];
|
||||
if (this.flareIntensity === 0) {
|
||||
this.releaseFlareSlot();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private releaseFlareSlot(): void {
|
||||
if (this.holdsFlareSlot) {
|
||||
this.holdsFlareSlot = false;
|
||||
FlareBudget.release();
|
||||
}
|
||||
}
|
||||
|
||||
private lastGeneratedPoint?: number;
|
||||
public generatedPoints(value: number) {
|
||||
this.lastGeneratedPoint = value;
|
||||
}
|
||||
|
||||
public onFlipped(team: CharacterTeam): void {
|
||||
const color = settings.palette[settings.colorIndices[team]];
|
||||
|
||||
if (!this.flareLight) {
|
||||
this.flareLight = new CircleLight(vec2.clone(this.center), vec3.clone(color), 0);
|
||||
} else {
|
||||
this.flareLight.color = vec3.clone(color);
|
||||
}
|
||||
|
||||
if (!this.holdsFlareSlot) {
|
||||
if (!FlareBudget.tryAcquire()) {
|
||||
return;
|
||||
}
|
||||
this.holdsFlareSlot = true;
|
||||
}
|
||||
|
||||
this.flareIntensity = 1;
|
||||
}
|
||||
|
||||
private beforeDestroy(): void {
|
||||
this.ownershipProgress.parentElement?.removeChild(this.ownershipProgress);
|
||||
this.generatedPointElements.forEach((p) =>
|
||||
p.element.parentElement?.removeChild(p.element),
|
||||
);
|
||||
this.releaseFlareSlot();
|
||||
}
|
||||
|
||||
private updateProperty({ propertyValue }: UpdatePropertyCommand): void {
|
||||
|
|
@ -80,26 +132,6 @@ export class PlanetView extends PlanetBase {
|
|||
|
||||
const screenPosition = renderer.worldToDisplayCoordinates(this.center);
|
||||
|
||||
this.generatedPointElements.forEach((p) => {
|
||||
if (!p.addedToOverlay) {
|
||||
overlay.appendChild(p.element);
|
||||
}
|
||||
|
||||
p.element.style.transform = `translateX(${
|
||||
screenPosition.x + p.position.x
|
||||
}px) translateY(${screenPosition.y + p.position.y}px)`;
|
||||
|
||||
if (p.timeToLive <= 0) {
|
||||
p.element.parentElement?.removeChild(p.element);
|
||||
} else {
|
||||
p.element.style.opacity = Math.min(1, p.timeToLive).toString();
|
||||
}
|
||||
});
|
||||
|
||||
this.generatedPointElements = this.generatedPointElements.filter(
|
||||
(p) => p.timeToLive > 0,
|
||||
);
|
||||
|
||||
this.ownershipProgress.style.transform = `translateX(${screenPosition.x}px) translateY(${screenPosition.y}px) translateX(-50%) translateY(-50%)`;
|
||||
this.ownershipProgress.style.background = this.getGradient();
|
||||
|
||||
|
|
@ -107,19 +139,23 @@ export class PlanetView extends PlanetBase {
|
|||
const element = document.createElement('div');
|
||||
element.className = 'falling-point ' + (this.ownership < 0.5 ? 'decla' : 'red');
|
||||
element.innerText = '+' + this.lastGeneratedPoint;
|
||||
this.generatedPointElements.push({
|
||||
element,
|
||||
addedToOverlay: false,
|
||||
timeToLive: Random.getRandomInRange(2, 3),
|
||||
position: vec2.create(),
|
||||
velocity: vec2.fromValues(Random.getRandomInRange(-30, 30), 0),
|
||||
});
|
||||
element.style.left = `${screenPosition.x}px`;
|
||||
element.style.top = `${screenPosition.y}px`;
|
||||
overlay.appendChild(element);
|
||||
setTimeout(
|
||||
() => element.parentElement?.removeChild(element),
|
||||
fallingPointLifetimeMs,
|
||||
);
|
||||
|
||||
this.lastGeneratedPoint = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
renderer.addDrawable(this.shape);
|
||||
|
||||
if (this.flareIntensity > 0 && this.flareLight) {
|
||||
renderer.addDrawable(this.flareLight);
|
||||
}
|
||||
}
|
||||
|
||||
private getGradient(): string {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,10 @@ export class ProjectileView extends ProjectileBase {
|
|||
|
||||
this.center = this.centerExtrapolator.getValue(deltaTimeInSeconds);
|
||||
this.light.center = this.center;
|
||||
this.light.intensity = (0.15 * this.strength) / settings.projectileMaxStrength;
|
||||
this.light.intensity = Math.min(
|
||||
0.1,
|
||||
(0.15 * this.strength) / settings.projectileMaxStrength,
|
||||
);
|
||||
}
|
||||
|
||||
private draw({ renderer }: RenderCommand): void {
|
||||
|
|
|
|||
71
frontend/src/scripts/scoreboard.ts
Normal file
71
frontend/src/scripts/scoreboard.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { CharacterTeam, UpdateGameState, clamp, settings } from 'shared';
|
||||
|
||||
// Centred tug-of-war territory bar. The two fills meet at the bar's centre and
|
||||
// grow outward by each team's share of the score limit, so the meeting point
|
||||
// reads as the lead at a glance. Numeric scores are overlaid (the leader's is
|
||||
// emphasised) and a "YOU" tick marks the local team's half. Owns its own DOM so
|
||||
// the Game just appends `element` to the overlay and feeds it `update()`.
|
||||
export class Scoreboard {
|
||||
public readonly element = document.createElement('div');
|
||||
|
||||
private readonly declaFill = document.createElement('div');
|
||||
private readonly redFill = document.createElement('div');
|
||||
private readonly declaScore = document.createElement('span');
|
||||
private readonly redScore = document.createElement('span');
|
||||
private readonly youMarker = document.createElement('div');
|
||||
|
||||
constructor() {
|
||||
this.element.className = 'planet-progress';
|
||||
|
||||
this.declaFill.className = 'fill decla';
|
||||
this.redFill.className = 'fill red';
|
||||
|
||||
this.declaScore.className = 'score decla';
|
||||
this.redScore.className = 'score red';
|
||||
|
||||
this.youMarker.className = 'you-marker';
|
||||
this.youMarker.innerText = 'YOU';
|
||||
|
||||
this.element.append(
|
||||
this.declaFill,
|
||||
this.redFill,
|
||||
this.declaScore,
|
||||
this.redScore,
|
||||
this.youMarker,
|
||||
);
|
||||
}
|
||||
|
||||
public update(
|
||||
{ declaCount, redCount, limit }: UpdateGameState,
|
||||
localTeam: CharacterTeam | undefined,
|
||||
) {
|
||||
const { scoreboardHalfWidthPercent, scoreboardMinFillPercent } = settings;
|
||||
const fraction = (count: number) =>
|
||||
Math.max(
|
||||
count > 0 ? scoreboardMinFillPercent : 0,
|
||||
clamp(count / limit, 0, 1) * scoreboardHalfWidthPercent,
|
||||
);
|
||||
|
||||
this.declaFill.style.width = fraction(declaCount) + '%';
|
||||
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.redScore.innerText = String(Math.round(redCount));
|
||||
|
||||
this.declaScore.classList.toggle('leading', declaCount > redCount);
|
||||
this.redScore.classList.toggle('leading', redCount > declaCount);
|
||||
|
||||
if (localTeam === CharacterTeam.decla || localTeam === CharacterTeam.red) {
|
||||
this.youMarker.style.display = '';
|
||||
this.youMarker.classList.toggle('decla', localTeam === CharacterTeam.decla);
|
||||
this.youMarker.classList.toggle('red', localTeam === CharacterTeam.red);
|
||||
} else {
|
||||
this.youMarker.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
import { mat2d, vec2 } from 'gl-matrix';
|
||||
import { Drawable, DrawableDescriptor } from 'sdf-2d';
|
||||
import { Circle } from 'shared';
|
||||
|
||||
export class BlobShape extends Drawable {
|
||||
public static descriptor: DrawableDescriptor = {
|
||||
sdf: {
|
||||
shader: `
|
||||
uniform vec2 headCenters[BLOB_COUNT];
|
||||
uniform vec2 leftFootCenters[BLOB_COUNT];
|
||||
uniform vec2 rightFootCenters[BLOB_COUNT];
|
||||
uniform float headRadii[BLOB_COUNT];
|
||||
uniform float footRadii[BLOB_COUNT];
|
||||
uniform int blobColors[BLOB_COUNT];
|
||||
|
||||
float blobSmoothMin(float a, float b)
|
||||
{
|
||||
const highp float k = 300.0;
|
||||
highp float res = exp2(-k * a) + exp2(-k * b);
|
||||
return -log2(res) / k;
|
||||
}
|
||||
|
||||
float circleDistance(vec2 circleCenter, float radius, vec2 target) {
|
||||
return distance(target, circleCenter) - radius;
|
||||
}
|
||||
|
||||
float blobMinDistance(vec2 target, out vec4 color) {
|
||||
float minDistance = 1000.0;
|
||||
|
||||
for (int i = 0; i < BLOB_COUNT; i++) {
|
||||
float headDistance = circleDistance(headCenters[i], headRadii[i], target);
|
||||
float leftFootDistance = circleDistance(leftFootCenters[i], footRadii[i], target);
|
||||
float rightFootDistance = circleDistance(rightFootCenters[i], footRadii[i], target);
|
||||
|
||||
float res = min(
|
||||
blobSmoothMin(headDistance, leftFootDistance),
|
||||
blobSmoothMin(headDistance, rightFootDistance)
|
||||
);
|
||||
|
||||
vec2 leftEyeOffset = vec2(-headRadii[i] * 0.35, headRadii[i] * 0.2);
|
||||
vec2 rightEyeOffset = vec2(headRadii[i] * 0.35, headRadii[i] * 0.2);
|
||||
|
||||
float eyeDistance = min(
|
||||
circleDistance(headCenters[i] + rightEyeOffset, headRadii[i] * 0.25, target),
|
||||
circleDistance(headCenters[i] + leftEyeOffset, headRadii[i] * 0.25, target)
|
||||
);
|
||||
|
||||
eyeDistance = max(
|
||||
eyeDistance,
|
||||
-circleDistance(headCenters[i] + leftEyeOffset + vec2(0, -headRadii[i] * 0.175), headRadii[i] * 0.2, target)
|
||||
);
|
||||
|
||||
eyeDistance = max(
|
||||
eyeDistance,
|
||||
-circleDistance(headCenters[i] + rightEyeOffset + vec2(0, -headRadii[i] * 0.175), headRadii[i] * 0.2, target)
|
||||
);
|
||||
|
||||
if (res < minDistance) {
|
||||
minDistance = res;
|
||||
color = eyeDistance < 0.0 ? vec4(1.0) : readFromPalette(blobColors[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return minDistance;
|
||||
}
|
||||
`,
|
||||
distanceFunctionName: 'blobMinDistance',
|
||||
},
|
||||
propertyUniformMapping: {
|
||||
footRadius: 'footRadii',
|
||||
headRadius: 'headRadii',
|
||||
rightFootCenter: 'rightFootCenters',
|
||||
leftFootCenter: 'leftFootCenters',
|
||||
headCenter: 'headCenters',
|
||||
color: 'blobColors',
|
||||
},
|
||||
uniformCountMacroName: 'BLOB_COUNT',
|
||||
shaderCombinationSteps: [0, 1, 2, 8],
|
||||
empty: new BlobShape(0),
|
||||
};
|
||||
|
||||
protected head!: Circle;
|
||||
protected leftFoot!: Circle;
|
||||
protected rightFoot!: Circle;
|
||||
|
||||
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,
|
||||
),
|
||||
headRadius: this.head.radius * transform1d,
|
||||
footRadius: this.leftFoot.radius * transform1d,
|
||||
color: this.color,
|
||||
};
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
|
|||
uniform float planetLengths[PLANET_COUNT];
|
||||
uniform float planetRandoms[PLANET_COUNT];
|
||||
uniform float planetColorMixQ[PLANET_COUNT];
|
||||
uniform float planetRotations[PLANET_COUNT];
|
||||
|
||||
uniform sampler2D noiseTexture;
|
||||
|
||||
|
|
@ -52,12 +53,20 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
|
|||
vec2 center = planetCenters[j];
|
||||
float l = planetLengths[j];
|
||||
float randomOffset = planetRandoms[j];
|
||||
float rotation = planetRotations[j];
|
||||
vec2 targetCenterDelta = target - center;
|
||||
float targetDistance = length(targetCenterDelta);
|
||||
vec2 targetTangent = targetCenterDelta / clamp(targetDistance, 0.01, 1000.0);
|
||||
|
||||
float cr = cos(rotation);
|
||||
float sr = sin(rotation);
|
||||
vec2 rotatedTangent = vec2(
|
||||
cr * targetTangent.x - sr * targetTangent.y,
|
||||
sr * targetTangent.x + cr * targetTangent.y
|
||||
);
|
||||
vec2 noisyTarget = target - (
|
||||
targetTangent * planetTerrain(vec2(
|
||||
l * abs(atan(targetTangent.y, targetTangent.x)),
|
||||
l * abs(atan(rotatedTangent.y, rotatedTangent.x)),
|
||||
randomOffset
|
||||
)) / 12.0
|
||||
);
|
||||
|
|
@ -105,6 +114,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
|
|||
center: 'planetCenters',
|
||||
vertices: 'planetVertices',
|
||||
colorMixQ: 'planetColorMixQ',
|
||||
rotation: 'planetRotations',
|
||||
},
|
||||
uniformCountMacroName: `PLANET_COUNT`,
|
||||
shaderCombinationSteps: [0, 1, 2, 3],
|
||||
|
|
@ -112,6 +122,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
|
|||
};
|
||||
|
||||
public randomOffset = 0;
|
||||
public rotation = 0;
|
||||
|
||||
constructor(
|
||||
public vertices: Array<vec2>,
|
||||
|
|
@ -142,6 +153,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
|
|||
length,
|
||||
random: this.randomOffset,
|
||||
colorMixQ: this.colorMixQ,
|
||||
rotation: this.rotation,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export abstract class SoundHandler {
|
|||
return sound;
|
||||
}
|
||||
|
||||
public static play(sound: Sounds, volume = 1) {
|
||||
public static play(sound: Sounds, volume = 1, playbackRate = 1) {
|
||||
if (!this.initialized || !OptionsHandler.options.soundsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -61,7 +61,8 @@ export abstract class SoundHandler {
|
|||
this.sounds[sound].currentTime > 0
|
||||
? (this.sounds[sound].cloneNode(true) as HTMLAudioElement)
|
||||
: this.sounds[sound];
|
||||
audio.volume = volume;
|
||||
audio.volume = Math.max(0, Math.min(1, volume));
|
||||
audio.playbackRate = playbackRate;
|
||||
audio.play();
|
||||
}
|
||||
|
||||
|
|
|
|||
105
frontend/src/scripts/tutorial.ts
Normal file
105
frontend/src/scripts/tutorial.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
CharacterTeam,
|
||||
CommandExecutors,
|
||||
CommandReceiver,
|
||||
Id,
|
||||
MoveActionCommand,
|
||||
PrimaryActionCommand,
|
||||
} from 'shared';
|
||||
import { GameObjectContainer } from './objects/game-object-container';
|
||||
import { PlanetView } from './objects/types/planet-view';
|
||||
|
||||
type StageTrigger = 'move' | 'shoot' | 'capture';
|
||||
|
||||
const stages: ReadonlyArray<{ hint: string; clearsOn: StageTrigger }> = [
|
||||
{ hint: 'WASD / drag to walk', clearsOn: 'move' },
|
||||
{ hint: 'Click / tap to shoot', clearsOn: 'shoot' },
|
||||
{ hint: 'Stand on a planet to capture it', clearsOn: 'capture' },
|
||||
];
|
||||
|
||||
const captureProgressThreshold = 0.05;
|
||||
|
||||
const standingSlack = 200;
|
||||
|
||||
export class Tutorial extends CommandReceiver {
|
||||
private stage = 0;
|
||||
private element = document.createElement('div');
|
||||
|
||||
private characterId?: Id;
|
||||
|
||||
private standingPlanet?: PlanetView;
|
||||
private standingBaseline = 0;
|
||||
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[MoveActionCommand.type]: (c: MoveActionCommand) => {
|
||||
if (this.clearsOn() === 'move' && vec2.length(c.direction) > 0) {
|
||||
this.advance();
|
||||
}
|
||||
},
|
||||
[PrimaryActionCommand.type]: () => {
|
||||
if (this.clearsOn() === 'shoot') {
|
||||
this.advance();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
constructor(overlay: HTMLElement) {
|
||||
super();
|
||||
this.element.className = 'tutorial-hint';
|
||||
this.element.innerText = stages[0].hint;
|
||||
overlay.appendChild(this.element);
|
||||
}
|
||||
|
||||
private clearsOn(): StageTrigger | undefined {
|
||||
return stages[this.stage]?.clearsOn;
|
||||
}
|
||||
|
||||
private advance() {
|
||||
this.setStage(this.stage + 1);
|
||||
}
|
||||
|
||||
public step(gameObjects: GameObjectContainer) {
|
||||
if (this.stage >= stages.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const player = gameObjects.player;
|
||||
if (!player) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.characterId === undefined) {
|
||||
this.characterId = player.id;
|
||||
} else if (player.id !== this.characterId) {
|
||||
this.finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.clearsOn() === 'capture') {
|
||||
const standing = gameObjects.planets.find(
|
||||
(p) => vec2.distance(p.center, player.bodyCenter) < p.radius + standingSlack,
|
||||
);
|
||||
if (standing !== this.standingPlanet) {
|
||||
this.standingPlanet = standing;
|
||||
this.standingBaseline = standing?.ownership ?? 0;
|
||||
}
|
||||
if (standing) {
|
||||
const drift = standing.ownership - this.standingBaseline;
|
||||
const progress = player.team === CharacterTeam.decla ? -drift : drift;
|
||||
if (progress > captureProgressThreshold) {
|
||||
this.finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private finish() {
|
||||
this.setStage(stages.length);
|
||||
}
|
||||
|
||||
private setStage(stage: number) {
|
||||
this.stage = stage;
|
||||
this.element.innerText = stages[stage]?.hint ?? '';
|
||||
}
|
||||
}
|
||||
|
|
@ -137,12 +137,6 @@ module.exports = (env, argv) => {
|
|||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.js', '.json'],
|
||||
alias: {
|
||||
// sdf-2d's package.json `exports` only declares an `import` condition,
|
||||
// which webpack 5 cannot resolve for the production (require) build.
|
||||
// Point straight at its entry to bypass package exports resolution.
|
||||
'sdf-2d$': path.resolve(__dirname, 'node_modules/sdf-2d/lib/main.js'),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ import { Command } from '../../command';
|
|||
|
||||
@serializable
|
||||
export class PrimaryActionCommand extends Command {
|
||||
public constructor(public readonly position: vec2) {
|
||||
public constructor(
|
||||
public readonly position: vec2,
|
||||
public readonly charge: number = 0,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public toArray(): Array<any> {
|
||||
return [this.position];
|
||||
return [this.position, this.charge];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { serializable } from '../../../serialization/serializable';
|
||||
import { Command } from '../../command';
|
||||
|
||||
@serializable
|
||||
export class SecondaryActionCommand extends Command {
|
||||
public constructor(public readonly position: vec2) {
|
||||
super();
|
||||
}
|
||||
|
||||
public toArray(): Array<any> {
|
||||
return [this.position];
|
||||
}
|
||||
}
|
||||
5
shared/src/helper/charge.ts
Normal file
5
shared/src/helper/charge.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { clamp01 } from './clamp';
|
||||
import { settings } from '../settings';
|
||||
|
||||
export const holdDurationToCharge = (heldSeconds: number): number =>
|
||||
clamp01(heldSeconds / settings.chargeShotFullHoldSeconds);
|
||||
|
|
@ -16,7 +16,6 @@ export * from './commands/command-generator';
|
|||
export * from './commands/types/actions/move-action';
|
||||
export * from './commands/types/actions/primary-action';
|
||||
export * from './commands/types/actions/set-aspect-ratio-action';
|
||||
export * from './commands/types/actions/secondary-action';
|
||||
export * from './helper/array';
|
||||
export * from './helper/last';
|
||||
export * from './helper/rgb';
|
||||
|
|
@ -24,6 +23,7 @@ export * from './helper/hsl';
|
|||
export * from './helper/rgb255';
|
||||
export * from './helper/mix-rgb';
|
||||
export * from './helper/clamp';
|
||||
export * from './helper/charge';
|
||||
export * from './helper/calculate-view-area';
|
||||
export * from './helper/circle';
|
||||
export * from './helper/rectangle';
|
||||
|
|
|
|||
|
|
@ -26,13 +26,17 @@ export class CharacterBase extends GameObject {
|
|||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public onShoot(strength: number) {}
|
||||
public onShoot(strength: number) { }
|
||||
|
||||
public onHitConfirmed() { }
|
||||
|
||||
public onKillConfirmed(victimName?: string, streak?: number) { }
|
||||
|
||||
public setHealth(health: number) {
|
||||
this.health = health;
|
||||
}
|
||||
|
||||
public onDie() {}
|
||||
public onDie() { }
|
||||
|
||||
public setKillCount(killCount: number) {
|
||||
this.killCount = killCount;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ export class LampBase extends GameObject {
|
|||
super(id);
|
||||
}
|
||||
|
||||
// Overridden by LampView (which lerps toward the new colour/brightness); a
|
||||
// no-op on the wire base.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public setLight(color: vec3, lightness: number) {}
|
||||
|
||||
public toArray(): Array<any> {
|
||||
const { id, center, color, lightness } = this;
|
||||
return [id, center, color, lightness];
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { settings } from '../../settings';
|
|||
import { serializable } from '../../serialization/serializable';
|
||||
import { GameObject } from '../game-object';
|
||||
import { Id } from '../../communication/id';
|
||||
import { CharacterTeam } from './character-base';
|
||||
|
||||
@serializable
|
||||
export class PlanetBase extends GameObject {
|
||||
|
|
@ -25,6 +26,8 @@ export class PlanetBase extends GameObject {
|
|||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public generatedPoints(value: number) {}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public onFlipped(team: CharacterTeam) {}
|
||||
|
||||
public static createPlanetVertices(
|
||||
center: vec2,
|
||||
|
|
|
|||
|
|
@ -6,39 +6,62 @@ const neutralColor = rgb255(82, 165, 64);
|
|||
const redColor = rgb255(209, 86, 82);
|
||||
const q = 2.5;
|
||||
const declaColorDim = rgb255(64 * q, 105 * q, 165 * q);
|
||||
const redColorDim = rgb255(209 * q, 8 * q, 82 * q);
|
||||
const redColorDim = rgb255(209 * q, 86 * q, 82 * q);
|
||||
const declaPlanetColor = declaColorDim;
|
||||
const redPlanetColor = redColorDim;
|
||||
|
||||
export const settings = {
|
||||
lightCutoffDistance: 600,
|
||||
lightOverlapReduction: 0.85,
|
||||
radiusSteps: 500,
|
||||
spawnDespawnTime: 0.7,
|
||||
worldRadius: 10000,
|
||||
worldRadius: 4000,
|
||||
objectsOnCircleLength: 0.002,
|
||||
updateMessageInterval: 1 / 25,
|
||||
planetEdgeCount: 7,
|
||||
playerKillPoint: 10,
|
||||
takeControlTimeInSeconds: 4,
|
||||
loseControlTimeInSeconds: 24,
|
||||
playerKillPoint: 25,
|
||||
takeControlTimeInSeconds: 2.5,
|
||||
loseControlTimeInSeconds: 16,
|
||||
planetPointGenerationInterval: 1.5,
|
||||
planetPointGenerationValue: 1,
|
||||
planetSizePointMultiplierMax: 4,
|
||||
captureFlipPointReward: 12,
|
||||
maxGravityDistance: 800,
|
||||
minGravityDistance: 1,
|
||||
maxGravityQ: 5000,
|
||||
planetControlThreshold: 0.2,
|
||||
playerMaxHealth: 100,
|
||||
maxGravityStrength: 50000,
|
||||
planetMinReferenceRadius: 150,
|
||||
planetMaxReferenceRadius: 1200,
|
||||
maxAcceleration: 120000,
|
||||
playerMaxStrength: 80,
|
||||
endGameDeltaScaling: 2,
|
||||
playerDiedTimeout: 5,
|
||||
playerStrengthRegenerationPerSeconds: 80,
|
||||
playerOutOfCombatDelaySeconds: 5,
|
||||
playerHealthRegenerationPerSeconds: 8,
|
||||
playerKillHealthReward: 25,
|
||||
spawnInvulnerabilityExtraSeconds: 0.4,
|
||||
spawnSafetyDistance: 2000,
|
||||
touchAimRange: 1000,
|
||||
postureHeadStiffness: 12,
|
||||
postureFeetStiffness: 10,
|
||||
planetDetachmentSeconds: 0.5,
|
||||
planetDetachmentForceThreshold: 100,
|
||||
climbDotThreshold: 0.8,
|
||||
climbGravityScale: 0.35,
|
||||
projectileMaxStrength: 40,
|
||||
projectileSpeed: 2500,
|
||||
projectileMaxBounceCount: 2,
|
||||
projectileTimeout: 3,
|
||||
projectileFadeSpeed: 20,
|
||||
projectileCreationInterval: 0.1,
|
||||
chargeShotFullHoldSeconds: 0.7,
|
||||
chargeShotStrengthMin: 40,
|
||||
chargeShotStrengthMax: 100,
|
||||
chargeShotRadiusMin: 20,
|
||||
chargeShotRadiusMax: 32,
|
||||
chargeShotSpeedMin: 2500,
|
||||
chargeShotSpeedMax: 3400,
|
||||
playerColorIndexOffset: 3,
|
||||
backgroundGradient: [rgb255(90, 38, 43), rgb255(43, 39, 73)],
|
||||
declaColor,
|
||||
|
|
@ -104,4 +127,14 @@ export const settings = {
|
|||
paletteDim: [declaColorDim, neutralColor, redColorDim],
|
||||
targetPhysicsDeltaTimeInSeconds: 1 / 200,
|
||||
inViewAreaSize: 1920 * 1080 * 4,
|
||||
scoreboardHalfWidthPercent: 50,
|
||||
scoreboardMinFillPercent: 1.5,
|
||||
matchPointScoreRatio: 0.9,
|
||||
lampLerpSeconds: 0.5,
|
||||
lampMinLightness: 0.4,
|
||||
lampMaxLightness: 1,
|
||||
lampFlareIntensity: 1.2,
|
||||
lampFlareDecaySeconds: 0.6,
|
||||
maxConcurrentFlipFlares: 3,
|
||||
announcementVisibleSeconds: 2,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue