diff --git a/Dockerfile b/Dockerfile index 2c1af5c..83f7922 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/backend/src/create-world.ts b/backend/src/create-world.ts index 1b846ba..b3941a0 100644 --- a/backend/src/create-world.ts +++ b/backend/src/create-world.ts @@ -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 = []; const lights: Array = []; - 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)); }; diff --git a/backend/src/default-options.ts b/backend/src/default-options.ts index 5a1c729..1580103 100644 --- a/backend/src/default-options.ts +++ b/backend/src/default-options.ts @@ -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, }; diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index 4e53039..1b51a35 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -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> = {}; 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 ${team}!`, + ), + ); } } diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index 21ac869..bd46282 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -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 = []; private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create()); @@ -128,12 +138,12 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical container.addObject(this.head); container.addObject(this.leftFoot); container.addObject(this.rightFoot); + } - this.bound = new CirclePhysical( - vec2.create(), - CharacterPhysical.boundRadius, - this, - container, + private get isSpawnProtected(): boolean { + return ( + this.timeAlive < + settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds ); } @@ -156,9 +166,33 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.movementActions.push(c); } - public addKill() { + public get groundPlanet(): PlanetPhysical | undefined { + return this.currentPlanet; + } + + public addKill(victimName: string) { this.killCount++; + this.killStreak++; this.remoteCall('setKillCount', this.killCount); + + this.health = Math.min( + settings.playerMaxHealth, + this.health + settings.playerKillHealthReward, + ); + this.syncHealth(); + this.remoteCall('onKillConfirmed', victimName, this.killStreak); + } + + public registerHit() { + this.remoteCall('onHitConfirmed'); + } + + private syncHealth() { + const rounded = Math.round(this.health); + if (rounded !== this.lastSyncedHealth) { + this.lastSyncedHealth = rounded; + this.remoteCall('setHealth', this.health); + } } public onCollision({ other }: ReactToCollisionCommand) { @@ -168,28 +202,54 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical other.isAlive ) { other.destroy(); + + if (this.isSpawnProtected) { + return; + } + + this.timeSinceLastDamage = 0; this.health -= other.strength; + this.lastSyncedHealth = Math.round(this.health); this.remoteCall('setHealth', this.health); + if (this.health <= 0 && this.isAlive) { this.onDie(); - other.originator.addKill(); + other.originator.addKill(this.name); + } else { + other.originator.registerHit(); } } } - public shootTowards(position: vec2) { - if (!this.isAlive) { + public shootTowards(position: vec2, charge = 0) { + if ( + !this.isAlive || + this.timeSinceLastShot < settings.projectileCreationInterval || + this.projectileStrength < settings.chargeShotStrengthMin + ) { return; } + this.timeSinceLastShot = 0; + + const c = clamp01(charge); + const desiredStrength = mix( + settings.chargeShotStrengthMin, + settings.chargeShotStrengthMax, + c, + ); + const strength = Math.min(desiredStrength, this.projectileStrength); + this.projectileStrength -= strength; + + const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c); + const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c); + const direction = vec2.subtract(vec2.create(), position, this.center); vec2.normalize(direction, direction); - const velocity = vec2.scale(direction, direction, settings.projectileSpeed); - const strength = this.projectileStrength / 2; - this.projectileStrength -= strength; + const velocity = vec2.scale(direction, direction, speed); const projectile = new ProjectilePhysical( vec2.clone(this.center), - 20, + radius, strength, this.team, velocity, @@ -202,8 +262,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } public get boundingBox(): BoundingBoxBase { - this.bound.center = this.head.center; - return this.bound.boundingBox; + return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius)); } public get gameObject(): this { @@ -257,6 +316,11 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical new UpdatePropertyCommand('head', this.head, this.headVelocity), new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity), new UpdatePropertyCommand('rightFoot', this.rightFoot, this.rightFootVelocity), + new UpdatePropertyCommand( + 'strength', + this.projectileStrength, + settings.playerStrengthRegenerationPerSeconds, + ), ]); } @@ -298,6 +362,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical private step({ deltaTimeInSeconds, game }: StepCommand) { this.getPoints(game); + this.timeAlive += deltaTimeInSeconds; const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius); const oldLeftFoot = new Circle( vec2.clone(this.leftFoot.center), @@ -328,16 +393,23 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical return; } - if ((this.secondsSinceOnSurface += deltaTimeInSeconds) > 0.5) { + if ( + (this.secondsSinceOnSurface += deltaTimeInSeconds) > + settings.planetDetachmentSeconds + ) { this.currentPlanet = undefined; } + this.timeSinceLastShot += deltaTimeInSeconds; + this.projectileStrength = Math.min( settings.playerMaxStrength, this.projectileStrength + - settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, + settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, ); + this.regenerateHealth(deltaTimeInSeconds); + this.currentPlanet?.takeControl(this.team, deltaTimeInSeconds); const intersectingWithForceField = this.container.findIntersecting( @@ -351,8 +423,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical const direction = this.averageAndResetMovementActions(); const movementForce = vec2.scale(direction, direction, settings.maxAcceleration); - this.applyForce(this.leftFoot, movementForce, deltaTimeInSeconds); - this.applyForce(this.rightFoot, movementForce, deltaTimeInSeconds); + this.leftFoot.applyForce(movementForce, deltaTimeInSeconds); + this.rightFoot.applyForce(movementForce, deltaTimeInSeconds); if (!this.currentPlanet) { const leftFootGravity = forceAtPosition( @@ -364,8 +436,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical intersectingWithForceField, ); - this.applyForce(this.leftFoot, leftFootGravity, deltaTimeInSeconds); - this.applyForce(this.rightFoot, rightFootGravity, deltaTimeInSeconds); + this.leftFoot.applyForce(leftFootGravity, deltaTimeInSeconds); + this.rightFoot.applyForce(rightFootGravity, deltaTimeInSeconds); const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce); @@ -377,8 +449,11 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical vec2.add(leftFootGravity, leftFootGravity, rightFootGravity); const gravity = vec2.scale(leftFootGravity, leftFootGravity, 0.5); - if (vec2.dot(movementForce, gravity) < -vec2.length(movementForce) * 0.8) { - vec2.scale(gravity, gravity, 0.35); + if ( + vec2.dot(movementForce, gravity) < + -vec2.length(movementForce) * settings.climbDotThreshold + ) { + vec2.scale(gravity, gravity, settings.climbGravityScale); } const scaledLeftFootGravity = vec2.scale( @@ -386,7 +461,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.leftFoot.lastNormal, vec2.dot(this.leftFoot.lastNormal, gravity), ); - this.applyForce(this.leftFoot, scaledLeftFootGravity, deltaTimeInSeconds); + this.leftFoot.applyForce(scaledLeftFootGravity, deltaTimeInSeconds); const scaledRightFootGravity = vec2.scale( vec2.create(), @@ -394,15 +469,15 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical vec2.dot(this.rightFoot.lastNormal, gravity), ); - this.applyForce(this.rightFoot, scaledRightFootGravity, deltaTimeInSeconds); + this.rightFoot.applyForce(scaledRightFootGravity, deltaTimeInSeconds); - if (vec2.length(gravity) <= 100) { + if (vec2.length(gravity) <= settings.planetDetachmentForceThreshold) { this.currentPlanet = undefined; } this.setDirection(gravity); } - this.keepPosture(deltaTimeInSeconds); + this.keepPosture(); this.stepBodyPart(this.leftFoot, deltaTimeInSeconds); this.stepBodyPart(this.rightFoot, deltaTimeInSeconds); this.stepBodyPart(this.head, deltaTimeInSeconds); @@ -418,56 +493,57 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical ); } - private keepPosture(deltaTime: number) { + private keepPosture() { const center = this.center; this.springMove( this.leftFoot, center, CharacterPhysical.leftFootOffset, - deltaTime, - 3000, + settings.postureFeetStiffness, ); this.springMove( this.rightFoot, center, CharacterPhysical.rightFootOffset, - deltaTime, - 3000, + settings.postureFeetStiffness, ); - this.springMove(this.head, center, CharacterPhysical.headOffset, deltaTime, 7000); + this.springMove( + this.head, + center, + CharacterPhysical.headOffset, + settings.postureHeadStiffness, + ); } private springMove( object: CirclePhysical, center: vec2, offset: vec2, - deltaTime: number, - strength: number, + stiffness: number, ) { const desiredPosition = vec2.add(vec2.create(), center, offset); vec2.rotate(desiredPosition, desiredPosition, center, this.direction); const positionDelta = vec2.subtract(vec2.create(), desiredPosition, object.center); - const positionDeltaLength = vec2.length(positionDelta); + // First-order velocity relaxation toward the desired posture position, + // added to (not replacing) the gravity/movement velocity accumulated + // earlier this tick. stepManually applies it over deltaTime, so the + // per-tick displacement is positionDelta * stiffness * deltaTime. + vec2.scaleAndAdd(object.velocity, object.velocity, positionDelta, stiffness); + } - if (positionDeltaLength > 0) { - const positionDeltaDirection = vec2.normalize(vec2.create(), positionDelta); - vec2.scale( - positionDelta, - positionDeltaDirection, - positionDeltaLength ** 2 * deltaTime * strength, + private regenerateHealth(deltaTimeInSeconds: number) { + this.timeSinceLastDamage += deltaTimeInSeconds; + if ( + this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds && + this.health < settings.playerMaxHealth + ) { + this.health = Math.min( + settings.playerMaxHealth, + this.health + settings.playerHealthRegenerationPerSeconds * deltaTimeInSeconds, ); - - if (vec2.length(positionDelta) * deltaTime * deltaTime > positionDeltaLength) { - vec2.scale( - positionDelta, - positionDelta, - positionDeltaLength / (vec2.length(positionDelta) * deltaTime * deltaTime), - ); - } - - object.applyForce(positionDelta, deltaTime); + this.syncHealth(); } } @@ -479,14 +555,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } } - public applyForce(circle: CirclePhysical, force: vec2, timeInSeconds: number) { - vec2.add( - circle.velocity, - circle.velocity, - vec2.scale(vec2.create(), force, timeInSeconds), - ); - } - public onDie() { this.isDestroyed = true; this.remoteCall('onDie'); diff --git a/backend/src/objects/lamp-physical.ts b/backend/src/objects/lamp-physical.ts index 3746c01..591b17c 100644 --- a/backend/src/objects/lamp-physical.ts +++ b/backend/src/objects/lamp-physical.ts @@ -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); } diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts index 5da432b..9e7b094 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -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 = []; + + 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) { 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), diff --git a/backend/src/objects/projectile-physical.ts b/backend/src/objects/projectile-physical.ts index 655ba42..51a7866 100644 --- a/backend/src/objects/projectile-physical.ts +++ b/backend/src/objects/projectile-physical.ts @@ -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( diff --git a/backend/src/options.ts b/backend/src/options.ts index 6304f77..ba28c16 100644 --- a/backend/src/options.ts +++ b/backend/src/options.ts @@ -5,5 +5,4 @@ export interface Options { scoreLimit: number; npcCount: number; seed: number; - worldSize: number; } diff --git a/backend/src/players/npc.ts b/backend/src/players/npc.ts index e9075c0..7bcb3f7 100644 --- a/backend/src/players/npc.ts +++ b/backend/src/players/npc.ts @@ -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 = []; + 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, - ): 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, - ): Array<{ planet: PlanetPhysical; distance: number }> { - const planets = nearObjects - .filter((o) => o.gameObject instanceof PlanetPhysical) - .map((c) => ({ - planet: c.gameObject, - distance: vec2.distance(this.center, (c.gameObject as PlanetPhysical).center), - })) as Array<{ planet: PlanetPhysical; distance: number }>; - - planets.sort((a, b) => a.distance - b.distance); - return planets; - } - - private findShootTarget(): vec2 | undefined { - const observableArea = getBoundingBoxOfCircle(new Circle(this.center, 1000)); - const nearObjects = this.objectContainer.findIntersecting(observableArea); - - const enemies = nearObjects.filter( - (o) => o.gameObject instanceof CharacterPhysical && o.gameObject.team !== this.team, - ); - if (enemies.length > 0) { - return (enemies[0].gameObject as CharacterPhysical).center; - } - } - private timeUntilRespawn = 0; public step(deltaTimeInSeconds: number) { if (this.character) { - this.center = this.character?.center; + this.center = this.character.center; if (!this.character.isAlive) { this.sumDeaths++; this.sumKills = this.character.killCount; - this.character = null; this.timeUntilRespawn = settings.playerDiedTimeout; + return; } } else { if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { this.createCharacter(); this.center = this.character!.center; } + return; } - if ((this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) > 3) { + if ( + (this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) > + npcTuning.wanderReconsiderSeconds + ) { this.timeSinceLastWanderingConsideration = 0; - this.isWandering = Random.getRandom() > 0.5; + this.isWandering = Random.getRandom() < npcTuning.wanderProbability; } - if ((this.timeSinceLastFindTarget += deltaTimeInSeconds) > 1) { - this.timeSinceLastFindTarget = 0; - this.findTarget(); + if ((this.timeSinceLastPlan += deltaTimeInSeconds) > npcTuning.planIntervalSeconds) { + this.timeSinceLastPlan = 0; + this.plan(); } - if ((this.timeSinceLastFindShootTarget += deltaTimeInSeconds) > 0.5) { - if (Random.getRandom() > 0.5) { - const shootTarget = this.findShootTarget(); - if (shootTarget) { - vec2.add( - shootTarget, - shootTarget, - vec2.fromValues( - Random.getRandomInRange(-200, 200), - Random.getRandomInRange(-200, 200), - ), - ); - this.character?.shootTowards(shootTarget); - } - } - - this.timeSinceLastFindShootTarget = 0; + if ( + (this.timeSinceObserve += deltaTimeInSeconds) > npcTuning.reactionIntervalSeconds + ) { + this.timeSinceObserve = 0; + this.nearObjects = this.observe(npcTuning.reactionObserveRadius); } - this.character?.handleMovementAction(new MoveActionCommand(this.direction)); + this.dodgeCommitRemaining -= deltaTimeInSeconds; + this.dodgeCooldownRemaining -= deltaTimeInSeconds; + const movement = this.decideMovement(this.nearObjects); + this.character.handleMovementAction(new MoveActionCommand(movement)); + + if ( + (this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds + ) { + this.timeSinceLastShoot = 0; + this.tryShoot(this.nearObjects); + } } - protected createCharacter() { - const randomPoint = vec2.rotate( + private plan() { + const distanceFromCentre = vec2.length(this.center); + if ( + (!this.isComingBack && distanceFromCentre > settings.worldRadius) || + (this.isComingBack && distanceFromCentre > settings.worldRadius / 2) + ) { + this.isComingBack = true; + vec2.negate(this.direction, this.center); + return; + } + this.isComingBack = false; + + const nearObjects = this.observe(npcTuning.planScanRadius); + const enemies = this.enemiesByDistance(nearObjects); + + if (enemies.length > 0) { + const nearest = enemies[0]; + const fleeRange = + npcTuning.fleeBaseRange * (npcTuning.fleeAggressionFalloff - this.aggression); + if (nearest.distance < fleeRange) { + vec2.subtract(this.direction, this.center, nearest.character.center); + return; + } + } + + if (enemies.length > 0) { + const nearest = enemies[0]; + const chaseRange = + npcTuning.chaseBaseRange + npcTuning.chaseAggressionRange * this.aggression; + if (nearest.distance < chaseRange) { + vec2.subtract(this.direction, nearest.character.center, this.center); + return; + } + } + + if (!this.isWandering) { + const planet = this.capturablePlanetsByDistance(nearObjects)[0]; + if (planet) { + vec2.subtract(this.direction, planet.planet.center, this.center); + return; + } + } + + vec2.rotate( + this.direction, + this.direction, vec2.create(), - vec2.fromValues(Random.getRandomInRange(0, settings.worldRadius), 0), - vec2.create(), - Random.getRandomInRange(0, Math.PI * 2), + Random.getRandomInRange(-npcTuning.wanderTurn, npcTuning.wanderTurn), ); - super.createCharacter(randomPoint); + } + + private decideMovement(nearObjects: Array): 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): 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) { + 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): boolean { + const planets: Array = []; + 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 { + return this.objectContainer.findIntersecting( + getBoundingBoxOfCircle(new Circle(this.center, radius)), + ); + } + + private enemiesByDistance( + nearObjects: Array, + ): Array<{ character: CharacterPhysical; distance: number }> { + const seen = new Set(); + 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, + ): 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; } } diff --git a/backend/src/players/player-base.ts b/backend/src/players/player-base.ts index 75fd21a..3d6a561 100644 --- a/backend/src/players/player-base.ts +++ b/backend/src/players/player-base.ts @@ -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, diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index 75e3e56..1478029 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -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(); - } } diff --git a/backend/src/react-to-collision.ts b/backend/src/react-to-collision.ts deleted file mode 100644 index fdcd492..0000000 --- a/backend/src/react-to-collision.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Command, GameObject } from 'shared'; - -export class ReactToCollisionCommand extends Command { - public constructor(public readonly other: GameObject) { - super(); - } -} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 972263f..9652017 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 9d94057..6dc2f68 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/main.scss b/frontend/src/main.scss index 4231090..08e6889 100644 --- a/frontend/src/main.scss +++ b/frontend/src/main.scss @@ -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; } - &::after { - content: ''; + .score { position: absolute; - left: 50%; top: 50%; - transform: translateX(-50%) translateY(50%); - - @include square(24px); - - background-image: url('../static/flag.svg'); - background-size: contain; + 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; } - div { - height: $height; - box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.2); + .score.decla { + right: calc(50% + #{$small-padding}); } - div:nth-child(1) { - background: $bright-decla; - border-radius: 100px 0 0 100px; + .score.red { + left: calc(50% + #{$small-padding}); } - div:nth-child(2) { - background: $bright-red; - border-radius: 0 100px 100px 0; + .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%; + bottom: 100%; + transform: translateX(-50%); + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-bottom: 5px solid currentColor; + } + + &.decla { + right: calc(50% + #{$small-padding}); + color: $bright-decla; + } + + &.red { + left: calc(50% + #{$small-padding}); + color: $bright-red; + } } } } @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/scripts/charge-indicator.ts b/frontend/src/scripts/charge-indicator.ts new file mode 100644 index 0000000..7b44718 --- /dev/null +++ b/frontend/src/scripts/charge-indicator.ts @@ -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); + }; +} diff --git a/frontend/src/scripts/commands/mouse-listener.ts b/frontend/src/scripts/commands/mouse-listener.ts index 23f2bd9..c4bd30d 100644 --- a/frontend/src/scripts/commands/mouse-listener.ts +++ b/frontend/src/scripts/commands/mouse-listener.ts @@ -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); } } diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts index 7f69f61..4ecb6bf 100644 --- a/frontend/src/scripts/commands/touch-listener.ts +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -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); } } diff --git a/frontend/src/scripts/feedback-hud.ts b/frontend/src/scripts/feedback-hud.ts new file mode 100644 index 0000000..801828b --- /dev/null +++ b/frontend/src/scripts/feedback-hud.ts @@ -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 ${this.escape(victimName ?? 'enemy')}`; + 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} +${settings.playerKillHealthReward}❤`; + 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; + } +} diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index 971ca98..bce7784 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -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,9 +194,8 @@ 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 - }px) translateX(-50%) translateY(-50%) rotate(${-angle + Math.PI / 2}rad) `; + e.style.transform = `translateX(${p.x}px) translateY(${p.y + }px) translateX(-50%) translateY(-50%) rotate(${-angle + Math.PI / 2}rad) `; }); for (const id in this.arrows) { @@ -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) { diff --git a/frontend/src/scripts/helper/pointer.ts b/frontend/src/scripts/helper/pointer.ts new file mode 100644 index 0000000..ab8886d --- /dev/null +++ b/frontend/src/scripts/helper/pointer.ts @@ -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; + } +} diff --git a/frontend/src/scripts/join-form-handler.ts b/frontend/src/scripts/join-form-handler.ts index 2d86296..e9c2b71 100644 --- a/frontend/src/scripts/join-form-handler.ts +++ b/frontend/src/scripts/join-form-handler.ts @@ -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]) => { diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index d09a99f..eb6b3dc 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -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 = new Map(); @@ -57,6 +58,16 @@ export class GameObjectContainer extends CommandReceiver { super(); } + public get planets(): Array { + const planets: Array = []; + 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); diff --git a/frontend/src/scripts/objects/types/character-view.ts b/frontend/src/scripts/objects/types/character-view.ts index d06d56e..034d97a 100644 --- a/frontend/src/scripts/objects/types/character-view.ts +++ b/frontend/src/scripts/objects/types/character-view.ts @@ -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 = + ''; +const deathIcon = + ''; 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); - SoundHandler.play( - Sounds.hit, - (0.4 * 2 * (previousHealth - health)) / settings.playerMaxStrength, - ); - if (this.isMainCharacter) { - VibrationHandler.vibrate(Math.min(200, (previousHealth - this.health) * 4)); + if (damage > 0) { + SoundHandler.play( + Sounds.hit, + 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, 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 { diff --git a/frontend/src/scripts/objects/types/lamp-view.ts b/frontend/src/scripts/objects/types/lamp-view.ts index 885ab85..e4db4f6 100644 --- a/frontend/src/scripts/objects/types/lamp-view.ts +++ b/frontend/src/scripts/objects/types/lamp-view.ts @@ -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 { diff --git a/frontend/src/scripts/objects/types/planet-view.ts b/frontend/src/scripts/objects/types/planet-view.ts index fbae00f..9c38963 100644 --- a/frontend/src/scripts/objects/types/planet-view.ts +++ b/frontend/src/scripts/objects/types/planet-view.ts @@ -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, 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), - ); + if (this.flareLight) { + this.flareLight.intensity = + settings.lampFlareIntensity * this.flareIntensity * this.flareIntensity; + } - p.timeToLive -= deltaTimeInSeconds; - }); + if (this.flareIntensity === 0) { + this.releaseFlareSlot(); + } + } } - private generatedPointElements: Array = []; + 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 { diff --git a/frontend/src/scripts/objects/types/projectile-view.ts b/frontend/src/scripts/objects/types/projectile-view.ts index a433ccd..7a246fe 100644 --- a/frontend/src/scripts/objects/types/projectile-view.ts +++ b/frontend/src/scripts/objects/types/projectile-view.ts @@ -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 { diff --git a/frontend/src/scripts/scoreboard.ts b/frontend/src/scripts/scoreboard.ts new file mode 100644 index 0000000..38dff4e --- /dev/null +++ b/frontend/src/scripts/scoreboard.ts @@ -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'; + } + } +} diff --git a/frontend/src/scripts/shapes/blob-shape.ts b/frontend/src/scripts/shapes/blob-shape.ts deleted file mode 100644 index 7e0d500..0000000 --- a/frontend/src/scripts/shapes/blob-shape.ts +++ /dev/null @@ -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, - }; - } -} diff --git a/frontend/src/scripts/shapes/character-shape.ts b/frontend/src/scripts/shapes/character-shape.ts new file mode 100644 index 0000000..711f70a --- /dev/null +++ b/frontend/src/scripts/shapes/character-shape.ts @@ -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, + }; + } +} diff --git a/frontend/src/scripts/shapes/planet-shape.ts b/frontend/src/scripts/shapes/planet-shape.ts index e35dc1d..6fa7565 100644 --- a/frontend/src/scripts/shapes/planet-shape.ts +++ b/frontend/src/scripts/shapes/planet-shape.ts @@ -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 ); @@ -89,8 +98,8 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { if (dist < minDistance) { minDistance = dist; color = mix(${colorToString(settings.declaPlanetColor)}, ${colorToString( - settings.redPlanetColor, - )}, planetColorMixQ[j]); + settings.redPlanetColor, + )}, planetColorMixQ[j]); } } @@ -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, @@ -142,6 +153,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { length, random: this.randomOffset, colorMixQ: this.colorMixQ, + rotation: this.rotation, }; } diff --git a/frontend/src/scripts/sound-handler.ts b/frontend/src/scripts/sound-handler.ts index f772049..81607ad 100644 --- a/frontend/src/scripts/sound-handler.ts +++ b/frontend/src/scripts/sound-handler.ts @@ -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(); } diff --git a/frontend/src/scripts/tutorial.ts b/frontend/src/scripts/tutorial.ts new file mode 100644 index 0000000..823ef40 --- /dev/null +++ b/frontend/src/scripts/tutorial.ts @@ -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 ?? ''; + } +} diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 0264401..97f6f6b 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -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'), - }, }, }; }; diff --git a/shared/src/commands/types/actions/primary-action.ts b/shared/src/commands/types/actions/primary-action.ts index 268828b..54b92c9 100644 --- a/shared/src/commands/types/actions/primary-action.ts +++ b/shared/src/commands/types/actions/primary-action.ts @@ -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 { - return [this.position]; + return [this.position, this.charge]; } } diff --git a/shared/src/commands/types/actions/secondary-action.ts b/shared/src/commands/types/actions/secondary-action.ts deleted file mode 100644 index 3ca0293..0000000 --- a/shared/src/commands/types/actions/secondary-action.ts +++ /dev/null @@ -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 { - return [this.position]; - } -} diff --git a/shared/src/helper/charge.ts b/shared/src/helper/charge.ts new file mode 100644 index 0000000..1ab6ece --- /dev/null +++ b/shared/src/helper/charge.ts @@ -0,0 +1,5 @@ +import { clamp01 } from './clamp'; +import { settings } from '../settings'; + +export const holdDurationToCharge = (heldSeconds: number): number => + clamp01(heldSeconds / settings.chargeShotFullHoldSeconds); diff --git a/shared/src/main.ts b/shared/src/main.ts index bc8cdec..949cc2d 100644 --- a/shared/src/main.ts +++ b/shared/src/main.ts @@ -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'; diff --git a/shared/src/objects/types/character-base.ts b/shared/src/objects/types/character-base.ts index 11540bd..69cc670 100644 --- a/shared/src/objects/types/character-base.ts +++ b/shared/src/objects/types/character-base.ts @@ -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; diff --git a/shared/src/objects/types/lamp-base.ts b/shared/src/objects/types/lamp-base.ts index 6990314..794e75b 100644 --- a/shared/src/objects/types/lamp-base.ts +++ b/shared/src/objects/types/lamp-base.ts @@ -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 { const { id, center, color, lightness } = this; return [id, center, color, lightness]; diff --git a/shared/src/objects/types/planet-base.ts b/shared/src/objects/types/planet-base.ts index 1ac50e3..ba90ff2 100644 --- a/shared/src/objects/types/planet-base.ts +++ b/shared/src/objects/types/planet-base.ts @@ -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, diff --git a/shared/src/settings.ts b/shared/src/settings.ts index 96a6275..f38bd81 100644 --- a/shared/src/settings.ts +++ b/shared/src/settings.ts @@ -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, };