diff --git a/Dockerfile b/Dockerfile index 83f7922..2c1af5c 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`, `--scoreLimit`. +# Override these to tune the server, e.g. `--name`, `--playerLimit`, `--worldSize`. CMD ["--port", "3000"] diff --git a/backend/src/create-world.ts b/backend/src/create-world.ts index b3941a0..1b846ba 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) => { +export const createWorld = (objectContainer: PhysicalContainer, worldRadius: number) => { const objects: Array = []; const lights: Array = []; - for (let r = 0; r < settings.worldRadius; r += settings.radiusSteps) { + for (let r = 0; r < 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,29 +67,8 @@ export const createWorld = (objectContainer: PhysicalContainer) => { } } } - 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); - }); + console.info('Generated planet count', objects.length); + console.info('Generated light count', lights.length); [...objects, ...lights].forEach((o) => objectContainer.addObject(o)); }; diff --git a/backend/src/default-options.ts b/backend/src/default-options.ts index 1580103..5a1c729 100644 --- a/backend/src/default-options.ts +++ b/backend/src/default-options.ts @@ -4,7 +4,8 @@ export const defaultOptions: Options = { port: 3000, name: 'Test server', playerLimit: 16, - npcCount: 8, + npcCount: 16, seed: Math.random(), - scoreLimit: 2500, + scoreLimit: 1000, + worldSize: 8000, }; diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index 1b51a35..4e53039 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -13,7 +13,6 @@ import { Command, CommandReceiver, CommandExecutors, - ServerAnnouncement, } from 'shared'; import { createWorld } from './create-world'; import { DeltaTimeCalculator } from './helper/delta-time-calculator'; @@ -32,7 +31,6 @@ export class GameServer extends CommandReceiver { private declaPoints = 0; private redPoints = 0; - private matchPointAnnounced: Partial> = {}; private isInEndGame = false; private timeScaling = 1; @@ -43,7 +41,7 @@ export class GameServer extends CommandReceiver { private initialize() { const previousPlayers = this.players; this.objects = new PhysicalContainer(); - createWorld(this.objects); + createWorld(this.objects, this.options.worldSize); this.objects.initialize(); this.players = new PlayerContainer( this.objects, @@ -54,7 +52,6 @@ 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()); @@ -129,23 +126,6 @@ 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 bd46282..21ac869 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -12,8 +12,6 @@ import { UpdatePropertyCommand, CommandExecutors, CommandReceiver, - mix, - clamp01, } from 'shared'; import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { CirclePhysical } from './circle-physical'; @@ -37,10 +35,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical private static readonly feetRadius = 20; private projectileStrength = settings.playerMaxStrength; - // 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); + // offsets are measured from (0, 0) + private static readonly desiredHeadOffset = vec2.fromValues(0, 65); private static readonly desiredLeftFootOffset = vec2.fromValues(-20, 0); private static readonly desiredRightFootOffset = vec2.fromValues(20, 0); private static readonly centerOfMass = vec2.scale( @@ -80,21 +76,15 @@ 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 = settings.planetDetachmentSeconds; + private secondsSinceOnSurface = 1000; public head: CirclePhysical; public leftFoot: CirclePhysical; public rightFoot: CirclePhysical; + public bound: CirclePhysical; private movementActions: Array = []; private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create()); @@ -138,12 +128,12 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical container.addObject(this.head); container.addObject(this.leftFoot); container.addObject(this.rightFoot); - } - private get isSpawnProtected(): boolean { - return ( - this.timeAlive < - settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds + this.bound = new CirclePhysical( + vec2.create(), + CharacterPhysical.boundRadius, + this, + container, ); } @@ -166,33 +156,9 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.movementActions.push(c); } - public get groundPlanet(): PlanetPhysical | undefined { - return this.currentPlanet; - } - - public addKill(victimName: string) { + public addKill() { 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) { @@ -202,54 +168,28 @@ 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(this.name); - } else { - other.originator.registerHit(); + other.originator.addKill(); } } } - public shootTowards(position: vec2, charge = 0) { - if ( - !this.isAlive || - this.timeSinceLastShot < settings.projectileCreationInterval || - this.projectileStrength < settings.chargeShotStrengthMin - ) { + public shootTowards(position: vec2) { + if (!this.isAlive) { 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, speed); + const velocity = vec2.scale(direction, direction, settings.projectileSpeed); + const strength = this.projectileStrength / 2; + this.projectileStrength -= strength; const projectile = new ProjectilePhysical( vec2.clone(this.center), - radius, + 20, strength, this.team, velocity, @@ -262,7 +202,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } public get boundingBox(): BoundingBoxBase { - return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius)); + this.bound.center = this.head.center; + return this.bound.boundingBox; } public get gameObject(): this { @@ -316,11 +257,6 @@ 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, - ), ]); } @@ -362,7 +298,6 @@ 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), @@ -393,23 +328,16 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical return; } - if ( - (this.secondsSinceOnSurface += deltaTimeInSeconds) > - settings.planetDetachmentSeconds - ) { + if ((this.secondsSinceOnSurface += deltaTimeInSeconds) > 0.5) { 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( @@ -423,8 +351,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical const direction = this.averageAndResetMovementActions(); const movementForce = vec2.scale(direction, direction, settings.maxAcceleration); - this.leftFoot.applyForce(movementForce, deltaTimeInSeconds); - this.rightFoot.applyForce(movementForce, deltaTimeInSeconds); + this.applyForce(this.leftFoot, movementForce, deltaTimeInSeconds); + this.applyForce(this.rightFoot, movementForce, deltaTimeInSeconds); if (!this.currentPlanet) { const leftFootGravity = forceAtPosition( @@ -436,8 +364,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical intersectingWithForceField, ); - this.leftFoot.applyForce(leftFootGravity, deltaTimeInSeconds); - this.rightFoot.applyForce(rightFootGravity, deltaTimeInSeconds); + this.applyForce(this.leftFoot, leftFootGravity, deltaTimeInSeconds); + this.applyForce(this.rightFoot, rightFootGravity, deltaTimeInSeconds); const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce); @@ -449,11 +377,8 @@ 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) * settings.climbDotThreshold - ) { - vec2.scale(gravity, gravity, settings.climbGravityScale); + if (vec2.dot(movementForce, gravity) < -vec2.length(movementForce) * 0.8) { + vec2.scale(gravity, gravity, 0.35); } const scaledLeftFootGravity = vec2.scale( @@ -461,7 +386,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.leftFoot.lastNormal, vec2.dot(this.leftFoot.lastNormal, gravity), ); - this.leftFoot.applyForce(scaledLeftFootGravity, deltaTimeInSeconds); + this.applyForce(this.leftFoot, scaledLeftFootGravity, deltaTimeInSeconds); const scaledRightFootGravity = vec2.scale( vec2.create(), @@ -469,15 +394,15 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical vec2.dot(this.rightFoot.lastNormal, gravity), ); - this.rightFoot.applyForce(scaledRightFootGravity, deltaTimeInSeconds); + this.applyForce(this.rightFoot, scaledRightFootGravity, deltaTimeInSeconds); - if (vec2.length(gravity) <= settings.planetDetachmentForceThreshold) { + if (vec2.length(gravity) <= 100) { this.currentPlanet = undefined; } this.setDirection(gravity); } - this.keepPosture(); + this.keepPosture(deltaTimeInSeconds); this.stepBodyPart(this.leftFoot, deltaTimeInSeconds); this.stepBodyPart(this.rightFoot, deltaTimeInSeconds); this.stepBodyPart(this.head, deltaTimeInSeconds); @@ -493,57 +418,56 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical ); } - private keepPosture() { + private keepPosture(deltaTime: number) { const center = this.center; this.springMove( this.leftFoot, center, CharacterPhysical.leftFootOffset, - settings.postureFeetStiffness, + deltaTime, + 3000, ); this.springMove( this.rightFoot, center, CharacterPhysical.rightFootOffset, - settings.postureFeetStiffness, + deltaTime, + 3000, ); - this.springMove( - this.head, - center, - CharacterPhysical.headOffset, - settings.postureHeadStiffness, - ); + this.springMove(this.head, center, CharacterPhysical.headOffset, deltaTime, 7000); } private springMove( object: CirclePhysical, center: vec2, offset: vec2, - stiffness: number, + deltaTime: number, + strength: 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); - // 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); - } + const positionDeltaLength = vec2.length(positionDelta); - 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 (positionDeltaLength > 0) { + const positionDeltaDirection = vec2.normalize(vec2.create(), positionDelta); + vec2.scale( + positionDelta, + positionDeltaDirection, + positionDeltaLength ** 2 * deltaTime * strength, ); - this.syncHealth(); + + if (vec2.length(positionDelta) * deltaTime * deltaTime > positionDeltaLength) { + vec2.scale( + positionDelta, + positionDelta, + positionDeltaLength / (vec2.length(positionDelta) * deltaTime * deltaTime), + ); + } + + object.applyForce(positionDelta, deltaTime); } } @@ -555,6 +479,14 @@ 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 591b17c..3746c01 100644 --- a/backend/src/objects/lamp-physical.ts +++ b/backend/src/objects/lamp-physical.ts @@ -32,10 +32,6 @@ 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 9e7b094..5da432b 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -4,7 +4,6 @@ import { clamp, clamp01, id, - mix, serializesTo, settings, PlanetBase, @@ -19,38 +18,20 @@ 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 { @@ -99,14 +80,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 ? value : 0, - this.team === CharacterTeam.red ? value : 0, + this.team === CharacterTeam.decla ? settings.planetPointGenerationValue : 0, + this.team === CharacterTeam.red ? settings.planetPointGenerationValue : 0, ), ); } @@ -114,40 +95,9 @@ 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 { @@ -203,7 +153,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(settings.minGravityDistance, vec2.length(diff) - this.radius); + const dist = Math.max(0, 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 51a7866..655ba42 100644 --- a/backend/src/objects/projectile-physical.ts +++ b/backend/src/objects/projectile-physical.ts @@ -53,13 +53,6 @@ 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 ba28c16..6304f77 100644 --- a/backend/src/options.ts +++ b/backend/src/options.ts @@ -5,4 +5,5 @@ 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 7bcb3f7..e9075c0 100644 --- a/backend/src/players/npc.ts +++ b/backend/src/players/npc.ts @@ -6,7 +6,6 @@ import { Random, MoveActionCommand, CharacterTeam, - Id, } from 'shared'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { PlayerContainer } from './player-container'; @@ -14,77 +13,16 @@ 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.fromValues(Random.getRandom() - 0.5, Random.getRandom() - 0.5); - private timeSinceLastPlan = 10000; - private timeSinceLastShoot = 10000; + private direction: vec2 = vec2.fromValues(Random.getRandom(), Random.getRandom()); + private timeSinceLastFindTarget = 10000; + private timeSinceLastFindShootTarget = 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, @@ -96,79 +34,35 @@ export class NPC extends PlayerBase { this.step(0); } - private timeUntilRespawn = 0; - public step(deltaTimeInSeconds: number) { - if (this.character) { - 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; - } - + private findTarget() { if ( - (this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) > - npcTuning.wanderReconsiderSeconds - ) { - this.timeSinceLastWanderingConsideration = 0; - this.isWandering = Random.getRandom() < npcTuning.wanderProbability; - } - - if ((this.timeSinceLastPlan += deltaTimeInSeconds) > npcTuning.planIntervalSeconds) { - this.timeSinceLastPlan = 0; - this.plan(); - } - - if ( - (this.timeSinceObserve += deltaTimeInSeconds) > npcTuning.reactionIntervalSeconds - ) { - this.timeSinceObserve = 0; - this.nearObjects = this.observe(npcTuning.reactionObserveRadius); - } - - this.dodgeCommitRemaining -= deltaTimeInSeconds; - this.dodgeCooldownRemaining -= deltaTimeInSeconds; - const movement = this.decideMovement(this.nearObjects); - this.character.handleMovementAction(new MoveActionCommand(movement)); - - if ( - (this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds - ) { - this.timeSinceLastShoot = 0; - this.tryShoot(this.nearObjects); - } - } - - private plan() { - const distanceFromCentre = vec2.length(this.center); - if ( - (!this.isComingBack && distanceFromCentre > settings.worldRadius) || - (this.isComingBack && distanceFromCentre > settings.worldRadius / 2) + (!this.isComingBack && vec2.length(this.center) > settings.worldRadius) || + (this.isComingBack && vec2.length(this.center) > settings.worldRadius / 2) ) { this.isComingBack = true; - vec2.negate(this.direction, this.center); + vec2.subtract(this.direction, vec2.fromValues(0, 0), this.center); return; } + this.isComingBack = false; - const nearObjects = this.observe(npcTuning.planScanRadius); - const enemies = this.enemiesByDistance(nearObjects); + 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]; - const fleeRange = - npcTuning.fleeBaseRange * (npcTuning.fleeAggressionFalloff - this.aggression); - if (nearest.distance < fleeRange) { + if (nearest.distance < 500) { vec2.subtract(this.direction, this.center, nearest.character.center); return; } @@ -176,225 +70,136 @@ export class NPC extends PlayerBase { if (enemies.length > 0) { const nearest = enemies[0]; - const chaseRange = - npcTuning.chaseBaseRange + npcTuning.chaseAggressionRange * this.aggression; - if (nearest.distance < chaseRange) { + if (nearest.distance > 1000) { 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; + 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; } } - - vec2.rotate( - this.direction, - this.direction, - vec2.create(), - Random.getRandomInRange(-npcTuning.wanderTurn, npcTuning.wanderTurn), - ); } - 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( + private findNearCharactersSorted( 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; + 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 capturablePlanetsByDistance( + private findNearPlanetsSorted( 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), - })); + .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; + + if (!this.character.isAlive) { + this.sumDeaths++; + this.sumKills = this.character.killCount; + + this.character = null; + this.timeUntilRespawn = settings.playerDiedTimeout; + } + } else { + if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { + this.createCharacter(); + this.center = this.character!.center; + } + } + + if ((this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) > 3) { + this.timeSinceLastWanderingConsideration = 0; + this.isWandering = Random.getRandom() > 0.5; + } + + if ((this.timeSinceLastFindTarget += deltaTimeInSeconds) > 1) { + this.timeSinceLastFindTarget = 0; + this.findTarget(); + } + + 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; + } + + this.character?.handleMovementAction(new MoveActionCommand(this.direction)); + } + + protected createCharacter() { + const randomPoint = vec2.rotate( + vec2.create(), + vec2.fromValues(Random.getRandomInRange(0, settings.worldRadius), 0), + vec2.create(), + Random.getRandomInRange(0, Math.PI * 2), + ); + super.createCharacter(randomPoint); + } } diff --git a/backend/src/players/player-base.ts b/backend/src/players/player-base.ts index 3d6a561..75fd21a 100644 --- a/backend/src/players/player-base.ts +++ b/backend/src/players/player-base.ts @@ -1,17 +1,9 @@ import { vec2 } from 'gl-matrix'; -import { - CommandReceiver, - Circle, - PlayerInformation, - CharacterTeam, - Random, - settings, -} from 'shared'; +import { CommandReceiver, Circle, PlayerInformation, CharacterTeam } 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 { @@ -30,14 +22,14 @@ export abstract class PlayerBase extends CommandReceiver { super(); } - protected createCharacter() { + protected createCharacter(preferredCenter: vec2) { this.character = new CharacterPhysical( this.playerInfo.name.slice(0, 20), this.sumKills, this.sumDeaths, this.team, this.objectContainer, - this.findEmptyPositionForPlayer(this.findSpawnCenter()), + this.findEmptyPositionForPlayer(preferredCenter), ); this.objectContainer.addObject(this.character); @@ -45,37 +37,14 @@ export abstract class PlayerBase extends CommandReceiver { public abstract step(deltaTimeInSeconds: number): void; - 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(); + protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 { + if (!preferredCenter) { + preferredCenter = 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 1478029..75e3e56 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -42,8 +42,9 @@ 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, c.charge), + [PrimaryActionCommand.type]: (c: PrimaryActionCommand) => { + this.character?.shootTowards(c.position); + }, }; constructor( @@ -59,7 +60,11 @@ export class Player extends PlayerBase { } protected createCharacter() { - super.createCharacter(); + const preferredCenter = this.playerContainer.players.find( + (p) => p.character?.isAlive && p.team === this.team, + )?.center; + + super.createCharacter(preferredCenter ?? vec2.create()); this.objectsPreviouslyInViewArea.push(this.character!); this.queueCommandSend(new CreatePlayerCommand(this.character!)); @@ -201,4 +206,8 @@ 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 new file mode 100644 index 0000000..fdcd492 --- /dev/null +++ b/backend/src/react-to-collision.ts @@ -0,0 +1,7 @@ +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 9652017..972263f 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": "file:../../sdf-2d", + "sdf-2d": "^0.7.6", "shared": "file:../shared", "socket.io-client": "^4.8.3", "socket.io-msgpack-parser": "^3.0.2", @@ -36,40 +36,6 @@ "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, @@ -5443,8 +5409,15 @@ } }, "node_modules/sdf-2d": { - "resolved": "../../sdf-2d", - "link": true + "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" + } }, "node_modules/select-hose": { "version": "2.0.0", diff --git a/frontend/package.json b/frontend/package.json index 6dc2f68..9d94057 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.8.0", + "sdf-2d": "^0.7.6", "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 08e6889..4231090 100644 --- a/frontend/src/main.scss +++ b/frontend/src/main.scss @@ -20,7 +20,6 @@ html { font-size: 0.85rem; - @media (max-width: $breakpoint) { font-size: 0.6rem; } @@ -31,21 +30,17 @@ 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; } @@ -53,7 +48,6 @@ h1 { .red { color: $accent; - &::selection { color: $accent; background-color: white; @@ -81,10 +75,8 @@ body { top: 0; @include background; - #spinner { @include square(20vmax); - @media (max-width: $breakpoint) { @include square(20vmax); } @@ -115,7 +107,7 @@ body { overflow: hidden; - &>* { + & > * { display: inline-block; position: absolute; top: 0; @@ -128,10 +120,8 @@ body { &.decla { color: $bright-decla; - - .health { + div:first-child { background-color: $bright-decla; - &:before { background-color: $bright-decla; opacity: 0.3; @@ -141,10 +131,8 @@ body { &.red { color: $bright-red; - - .health { + div:first-child { background-color: $bright-red; - &:before { background-color: $bright-red; opacity: 0.4; @@ -152,7 +140,7 @@ body { } } - .health { + div:first-child { position: relative; height: 5px; border-radius: 1000px; @@ -167,33 +155,9 @@ body { } } - .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; + div:not(:first-child) { + letter-spacing: 2px; 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)); - } } } @@ -208,13 +172,9 @@ 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; } @@ -224,7 +184,6 @@ body { transition: transform 150ms; @include square($large-icon); - @media (max-width: $breakpoint) { @include square($small-icon); } @@ -260,56 +219,6 @@ 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%); @@ -333,65 +242,20 @@ 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%; - $height: 20px; + display: flex; + $height: 8px; 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: ''; @@ -399,87 +263,38 @@ body { left: 50%; top: 50%; transform: translateX(-50%) translateY(-50%); - background-color: #fff; - height: $height + 8px; - width: 3px; + background-color: #888; + height: 24px; + width: 4px; border-radius: 1000px; - z-index: 2; } - .score { + &::after { + content: ''; position: absolute; + left: 50%; top: 50%; - transform: translateY(-50%); - z-index: 3; - font-size: 0.95rem; - font-weight: 700; - line-height: 1; - color: #fff; - text-shadow: - 0 0 3px rgba(0, 0, 0, 0.95), - 0 0 6px rgba(0, 0, 0, 0.8); - opacity: 0.8; - transition: - opacity $animation-time, - text-shadow $animation-time, - font-size $animation-time; + transform: translateX(-50%) translateY(50%); + + @include square(24px); + + background-image: url('../static/flag.svg'); + background-size: contain; } - .score.decla { - right: calc(50% + #{$small-padding}); + div { + height: $height; + box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.2); } - .score.red { - left: calc(50% + #{$small-padding}); + div:nth-child(1) { + background: $bright-decla; + border-radius: 100px 0 0 100px; } - .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; - } + div:nth-child(2) { + background: $bright-red; + border-radius: 0 100px 100px 0; } } } @@ -493,7 +308,6 @@ body { background-color: transparent; width: 3px; } - &::-webkit-scrollbar-thumb { background-color: $accent; border-radius: $border-radius; @@ -507,10 +321,6 @@ body { bottom: 0; left: 0; - @media (hover: none) and (pointer: coarse) { - display: none; - } - box-sizing: content-box; user-select: none; @@ -518,7 +328,6 @@ body { padding: $medium-padding; @include square($large-icon); - @media (max-width: $breakpoint) { @include square($small-icon); padding: $small-padding; @@ -529,217 +338,3 @@ 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 deleted file mode 100644 index 7b44718..0000000 --- a/frontend/src/scripts/charge-indicator.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 c4bd30d..23f2bd9 100644 --- a/frontend/src/scripts/commands/mouse-listener.ts +++ b/frontend/src/scripts/commands/mouse-listener.ts @@ -1,14 +1,8 @@ import { vec2 } from 'gl-matrix'; -import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared'; +import { CommandGenerator, PrimaryActionCommand, SecondaryActionCommand } 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, @@ -16,43 +10,22 @@ 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); } - // 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) => { + const position = this.positionFromEvent(event); + if (event.button === 0) { - 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); + this.sendCommandToSubscribers(new PrimaryActionCommand(position)); } }; - 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 { @@ -62,10 +35,7 @@ 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 4ecb6bf..7f69f61 100644 --- a/frontend/src/scripts/commands/touch-listener.ts +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -1,14 +1,6 @@ import { vec2 } from 'gl-matrix'; -import { - CommandGenerator, - MoveActionCommand, - last, - PrimaryActionCommand, - holdDurationToCharge, - settings, -} from 'shared'; +import { CommandGenerator, MoveActionCommand, last, PrimaryActionCommand } from 'shared'; import { Game } from '../game'; -import { ChargeIndicator } from '../charge-indicator'; export class TouchListener extends CommandGenerator { private static readonly deadZone = 8; @@ -18,12 +10,6 @@ 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, @@ -37,17 +23,6 @@ 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); @@ -68,8 +43,6 @@ 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); } }; @@ -87,8 +60,6 @@ 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%)`; @@ -100,8 +71,7 @@ export class TouchListener extends CommandGenerator { vec2.set(delta, delta.x, -delta.y); if (deltaLength > TouchListener.deadZone) { - const direction = vec2.normalize(delta, delta); - this.sendCommandToSubscribers(new MoveActionCommand(direction)); + this.sendCommandToSubscribers(new MoveActionCommand(vec2.normalize(delta, delta))); } else { this.sendCommandToSubscribers(new MoveActionCommand(vec2.create())); } @@ -111,18 +81,12 @@ 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), charge), + new PrimaryActionCommand(this.game.displayToWorldCoordinates(center)), ); } else if (event.touches.length === 0) { this.isJoystickActive = false; @@ -131,62 +95,9 @@ 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 deleted file mode 100644 index 801828b..0000000 --- a/frontend/src/scripts/feedback-hud.ts +++ /dev/null @@ -1,89 +0,0 @@ -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 bce7784..971ca98 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -30,12 +30,10 @@ 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 { CharacterShape } from './shapes/character-shape'; +import { BlobShape } from './shapes/blob-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); @@ -50,11 +48,12 @@ export class Game extends CommandReceiver { private mouseListener: MouseListener; private touchListener: TouchListener; - private scoreboard = new Scoreboard(); + private declaPlanetCountElement = document.createElement('div'); + private redPlanetCountElement = document.createElement('div'); 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, @@ -64,6 +63,9 @@ 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); @@ -76,14 +78,12 @@ 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.scoreboard.element); + this.overlay.appendChild(this.progressBar); 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,17 +114,12 @@ 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; @@ -194,8 +189,9 @@ 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) { @@ -218,7 +214,7 @@ export class Game extends CommandReceiver { this.canvas, [ PlanetShape.descriptor, - CharacterShape.descriptor, + BlobShape.descriptor, { ...CircleLight.descriptor, shaderCombinationSteps: [0, 1, 2, 4, 8, 16], @@ -227,11 +223,10 @@ export class Game extends CommandReceiver { this.gameLoop.bind(this), { shadowTraceCount: 16, - paletteSize: settings.paletteDim.length, - colorPalette: settings.paletteDim, + paletteSize: settings.palette.length, + colorPalette: settings.palette, enableHighDpiRendering: true, lightCutoffDistance: settings.lightCutoffDistance, - lightOverlapReduction: settings.lightOverlapReduction, textures: { noiseTexture: { source: noiseTexture, @@ -281,9 +276,7 @@ export class Game extends CommandReceiver { this.draw(); } - if ( - (this.timeSinceLastAnnouncement += deltaTime) > settings.announcementVisibleSeconds - ) { + if ((this.timeSinceLastAnnouncement += deltaTime) > 0.5) { this.lastAnnouncementText = ''; } @@ -299,10 +292,6 @@ 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; @@ -310,8 +299,10 @@ export class Game extends CommandReceiver { private draw() { if (this.lastGameState) { - // The local player's team is read off the main character once it exists. - this.scoreboard.update(this.lastGameState, this.gameObjects.player?.team); + this.declaPlanetCountElement.style.width = + (this.lastGameState.declaCount / this.lastGameState.limit) * 50 + '%'; + this.redPlanetCountElement.style.width = + (this.lastGameState.redCount / this.lastGameState.limit) * 50 + '%'; } if (this.lastOtherPlayerDirections) { diff --git a/frontend/src/scripts/helper/pointer.ts b/frontend/src/scripts/helper/pointer.ts deleted file mode 100644 index ab8886d..0000000 --- a/frontend/src/scripts/helper/pointer.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 e9c2b71..2d86296 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,17 +143,16 @@ class ServerChooserOption { this.setServerInfoLabelText(); this.socket = io(url, { - reconnection: true, - reconnectionAttempts: 5, + reconnection: false, timeout: 4000, parser, } as any); - this.socket.io.on('reconnect_failed', this.destroy.bind(this)); - - this.socket.on('connect', () => - this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates), - ); + // `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.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 eb6b3dc..d09a99f 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -15,7 +15,6 @@ 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(); @@ -58,16 +57,6 @@ 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 034d97a..d06d56e 100644 --- a/frontend/src/scripts/objects/types/character-view.ts +++ b/frontend/src/scripts/objects/types/character-view.ts @@ -1,5 +1,4 @@ import { vec2 } from 'gl-matrix'; -import { CircleLight, Renderer } from 'sdf-2d'; import { Circle, @@ -7,9 +6,6 @@ import { CharacterBase, CharacterTeam, settings, - clamp, - clamp01, - mix, CommandExecutors, UpdatePropertyCommand, } from 'shared'; @@ -17,36 +13,15 @@ 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 { LinearExtrapolator } from '../../helper/extrapolators/linear-extrapolator'; -import { Pointer } from '../../helper/pointer'; -import { CharacterShape } from '../../shapes/character-shape'; +import { BlobShape } from '../../shapes/blob-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: CharacterShape; - private muzzleFlash: CircleLight; - private muzzleFlashIntensity = 0; - private hitFlashIntensity = 0; - private strength = settings.playerMaxStrength; - private strengthExtrapolator = new LinearExtrapolator(settings.playerMaxStrength); + private shape: BlobShape; 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; @@ -73,12 +48,7 @@ export class CharacterView extends CharacterBase { rightFoot?: Circle, ) { super(id, name, killCount, deathCount, team, health, head, leftFoot, rightFoot); - this.shape = new CharacterShape(settings.colorIndices[team]); - this.muzzleFlash = new CircleLight( - vec2.clone(this.head!.center), - settings.paletteDim[settings.colorIndices[team]], - 0, - ); + this.shape = new BlobShape(settings.colorIndices[team]); this.leftFootExtrapolator = new CircleExtrapolator(this.leftFoot!); this.rightFootExtrapolator = new CircleExtrapolator(this.rightFoot!); @@ -86,24 +56,7 @@ 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); } @@ -111,29 +64,6 @@ 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, @@ -148,25 +78,18 @@ 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 damage = this.health - health; + const previousHealth = this.health; super.setHealth(health); + SoundHandler.play( + Sounds.hit, + (0.4 * 2 * (previousHealth - health)) / settings.playerMaxStrength, + ); - 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)); - } + if (this.isMainCharacter) { + VibrationHandler.vibrate(Math.min(200, (previousHealth - this.health) * 4)); } } @@ -176,58 +99,14 @@ 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) { - 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); + SoundHandler.play(Sounds.shoot, (0.6 * strength) / settings.playerMaxStrength); } private beforeDestroy(): void { @@ -248,34 +127,15 @@ export class CharacterView extends CharacterBase { this.healthElement.style.width = (50 * this.health) / settings.playerMaxHealth + 'px'; - this.chargeElement.style.width = - (50 * this.strength) / settings.playerMaxStrength + 'px'; - this.killCountElement.innerText = String(this.killCount); - this.deathCountElement.innerText = String(this.deathCount); + this.statsElement.innerText = this.getStatsText(); } - 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 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 getStatsText(): string { + return `${this.killCount}⚔/${this.deathCount}☠`; } private calculateTextPosition(): vec2 { diff --git a/frontend/src/scripts/objects/types/lamp-view.ts b/frontend/src/scripts/objects/types/lamp-view.ts index e4db4f6..885ab85 100644 --- a/frontend/src/scripts/objects/types/lamp-view.ts +++ b/frontend/src/scripts/objects/types/lamp-view.ts @@ -1,36 +1,18 @@ import { vec2, vec3 } from 'gl-matrix'; import { CircleLight } from 'sdf-2d'; -import { CommandExecutors, Id, LampBase, mixRgb, settings } from 'shared'; +import { CommandExecutors, Id, LampBase } 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(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; + this.light = new CircleLight(center, color, lightness); } 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 9c38963..fbae00f 100644 --- a/frontend/src/scripts/objects/types/planet-view.ts +++ b/frontend/src/scripts/objects/types/planet-view.ts @@ -1,49 +1,21 @@ -import { vec2, vec3 } from 'gl-matrix'; -import { CircleLight } from 'sdf-2d'; -import { - Id, - Random, - PlanetBase, - UpdatePropertyCommand, - CommandExecutors, - CharacterTeam, - settings, -} from 'shared'; +import { vec2 } from 'gl-matrix'; +import { Id, Random, PlanetBase, UpdatePropertyCommand, CommandExecutors } 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'; -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); - } -} +type FallingPoint = { + velocity: vec2; + position: vec2; + element: HTMLElement; + addedToOverlay: boolean; + timeToLive: number; +}; 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), @@ -55,69 +27,45 @@ export class PlanetView extends PlanetBase { constructor(id: Id, vertices: Array, ownership: number) { super(id, vertices); this.shape = new PlanetShape(vertices, ownership); - this.shape.randomOffset = Random.getRandom(); - this.rotationSpeed = - (0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1); + (this.shape as any).randomOffset = Random.getRandom(); this.ownershipProgress = document.createElement('div'); this.ownershipProgress.className = 'ownership'; } private step({ deltaTimeInSeconds }: StepCommand): void { - this.shape.rotation += deltaTimeInSeconds * this.rotationSpeed; + this.shape.randomOffset += deltaTimeInSeconds / 4; this.shape.colorMixQ = this.ownership; - if (this.flareIntensity > 0) { - this.flareIntensity = Math.max( - 0, - this.flareIntensity - deltaTimeInSeconds / settings.lampFlareDecaySeconds, + this.generatedPointElements.forEach((p) => { + vec2.add( + p.velocity, + p.velocity, + vec2.scale(vec2.create(), vec2.fromValues(0, 50), deltaTimeInSeconds), ); - if (this.flareLight) { - this.flareLight.intensity = - settings.lampFlareIntensity * this.flareIntensity * this.flareIntensity; - } + vec2.add( + p.position, + p.position, + vec2.scale(vec2.create(), p.velocity, deltaTimeInSeconds), + ); - if (this.flareIntensity === 0) { - this.releaseFlareSlot(); - } - } + p.timeToLive -= deltaTimeInSeconds; + }); } - private releaseFlareSlot(): void { - if (this.holdsFlareSlot) { - this.holdsFlareSlot = false; - FlareBudget.release(); - } - } + private generatedPointElements: Array = []; 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.releaseFlareSlot(); + this.generatedPointElements.forEach((p) => + p.element.parentElement?.removeChild(p.element), + ); } private updateProperty({ propertyValue }: UpdatePropertyCommand): void { @@ -132,6 +80,26 @@ 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(); @@ -139,23 +107,19 @@ export class PlanetView extends PlanetBase { const element = document.createElement('div'); element.className = 'falling-point ' + (this.ownership < 0.5 ? 'decla' : 'red'); element.innerText = '+' + this.lastGeneratedPoint; - element.style.left = `${screenPosition.x}px`; - element.style.top = `${screenPosition.y}px`; - overlay.appendChild(element); - setTimeout( - () => element.parentElement?.removeChild(element), - fallingPointLifetimeMs, - ); + this.generatedPointElements.push({ + element, + addedToOverlay: false, + timeToLive: Random.getRandomInRange(2, 3), + position: vec2.create(), + velocity: vec2.fromValues(Random.getRandomInRange(-30, 30), 0), + }); 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 7a246fe..a433ccd 100644 --- a/frontend/src/scripts/objects/types/projectile-view.ts +++ b/frontend/src/scripts/objects/types/projectile-view.ts @@ -48,10 +48,7 @@ export class ProjectileView extends ProjectileBase { this.center = this.centerExtrapolator.getValue(deltaTimeInSeconds); this.light.center = this.center; - this.light.intensity = Math.min( - 0.1, - (0.15 * this.strength) / settings.projectileMaxStrength, - ); + this.light.intensity = (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 deleted file mode 100644 index 38dff4e..0000000 --- a/frontend/src/scripts/scoreboard.ts +++ /dev/null @@ -1,71 +0,0 @@ -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 new file mode 100644 index 0000000..7e0d500 --- /dev/null +++ b/frontend/src/scripts/shapes/blob-shape.ts @@ -0,0 +1,125 @@ +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 deleted file mode 100644 index 711f70a..0000000 --- a/frontend/src/scripts/shapes/character-shape.ts +++ /dev/null @@ -1,219 +0,0 @@ -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 6fa7565..e35dc1d 100644 --- a/frontend/src/scripts/shapes/planet-shape.ts +++ b/frontend/src/scripts/shapes/planet-shape.ts @@ -14,7 +14,6 @@ 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; @@ -53,20 +52,12 @@ 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(rotatedTangent.y, rotatedTangent.x)), + l * abs(atan(targetTangent.y, targetTangent.x)), randomOffset )) / 12.0 ); @@ -98,8 +89,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]); } } @@ -114,7 +105,6 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { center: 'planetCenters', vertices: 'planetVertices', colorMixQ: 'planetColorMixQ', - rotation: 'planetRotations', }, uniformCountMacroName: `PLANET_COUNT`, shaderCombinationSteps: [0, 1, 2, 3], @@ -122,7 +112,6 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { }; public randomOffset = 0; - public rotation = 0; constructor( public vertices: Array, @@ -153,7 +142,6 @@ 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 81607ad..f772049 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, playbackRate = 1) { + public static play(sound: Sounds, volume = 1) { if (!this.initialized || !OptionsHandler.options.soundsEnabled) { return; } @@ -61,8 +61,7 @@ export abstract class SoundHandler { this.sounds[sound].currentTime > 0 ? (this.sounds[sound].cloneNode(true) as HTMLAudioElement) : this.sounds[sound]; - audio.volume = Math.max(0, Math.min(1, volume)); - audio.playbackRate = playbackRate; + audio.volume = volume; audio.play(); } diff --git a/frontend/src/scripts/tutorial.ts b/frontend/src/scripts/tutorial.ts deleted file mode 100644 index 823ef40..0000000 --- a/frontend/src/scripts/tutorial.ts +++ /dev/null @@ -1,105 +0,0 @@ -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 97f6f6b..0264401 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -137,6 +137,12 @@ 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 54b92c9..268828b 100644 --- a/shared/src/commands/types/actions/primary-action.ts +++ b/shared/src/commands/types/actions/primary-action.ts @@ -4,14 +4,11 @@ import { Command } from '../../command'; @serializable export class PrimaryActionCommand extends Command { - public constructor( - public readonly position: vec2, - public readonly charge: number = 0, - ) { + public constructor(public readonly position: vec2) { super(); } public toArray(): Array { - return [this.position, this.charge]; + return [this.position]; } } diff --git a/shared/src/commands/types/actions/secondary-action.ts b/shared/src/commands/types/actions/secondary-action.ts new file mode 100644 index 0000000..3ca0293 --- /dev/null +++ b/shared/src/commands/types/actions/secondary-action.ts @@ -0,0 +1,14 @@ +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 deleted file mode 100644 index 1ab6ece..0000000 --- a/shared/src/helper/charge.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 949cc2d..bc8cdec 100644 --- a/shared/src/main.ts +++ b/shared/src/main.ts @@ -16,6 +16,7 @@ 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'; @@ -23,7 +24,6 @@ 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 69cc670..11540bd 100644 --- a/shared/src/objects/types/character-base.ts +++ b/shared/src/objects/types/character-base.ts @@ -26,17 +26,13 @@ export class CharacterBase extends GameObject { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onShoot(strength: number) { } - - public onHitConfirmed() { } - - public onKillConfirmed(victimName?: string, streak?: number) { } + public onShoot(strength: 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 794e75b..6990314 100644 --- a/shared/src/objects/types/lamp-base.ts +++ b/shared/src/objects/types/lamp-base.ts @@ -13,11 +13,6 @@ 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 ba90ff2..1ac50e3 100644 --- a/shared/src/objects/types/planet-base.ts +++ b/shared/src/objects/types/planet-base.ts @@ -4,7 +4,6 @@ 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 { @@ -26,8 +25,6 @@ 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 f38bd81..96a6275 100644 --- a/shared/src/settings.ts +++ b/shared/src/settings.ts @@ -6,62 +6,39 @@ 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, 86 * q, 82 * q); +const redColorDim = rgb255(209 * q, 8 * q, 82 * q); const declaPlanetColor = declaColorDim; const redPlanetColor = redColorDim; export const settings = { lightCutoffDistance: 600, - lightOverlapReduction: 0.85, radiusSteps: 500, spawnDespawnTime: 0.7, - worldRadius: 4000, + worldRadius: 10000, objectsOnCircleLength: 0.002, updateMessageInterval: 1 / 25, planetEdgeCount: 7, - playerKillPoint: 25, - takeControlTimeInSeconds: 2.5, - loseControlTimeInSeconds: 16, + playerKillPoint: 10, + takeControlTimeInSeconds: 4, + loseControlTimeInSeconds: 24, 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, @@ -127,14 +104,4 @@ 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, };