From e3c44f775beb564bf4c56894cf4947357764e162 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Thu, 11 Jun 2026 21:33:08 +0100 Subject: [PATCH] More improvements --- backend/src/game-server.ts | 20 + backend/src/objects/character-physical.ts | 196 ++++--- backend/src/objects/planet-physical.ts | 62 ++- backend/src/players/npc.ts | 478 ++++++++++++------ .../src/scripts/commands/mouse-listener.ts | 44 +- .../src/scripts/commands/touch-listener.ts | 95 +++- frontend/src/scripts/feedback-hud.ts | 9 - .../scripts/objects/game-object-container.ts | 11 + frontend/src/scripts/scoreboard.ts | 5 + .../src/scripts/shapes/character-shape.ts | 219 ++++++++ frontend/src/scripts/shapes/planet-shape.ts | 2 - 11 files changed, 908 insertions(+), 233 deletions(-) create mode 100644 frontend/src/scripts/shapes/character-shape.ts diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index 78fc5d3..1b51a35 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -13,6 +13,7 @@ import { Command, CommandReceiver, CommandExecutors, + ServerAnnouncement, } from 'shared'; import { createWorld } from './create-world'; import { DeltaTimeCalculator } from './helper/delta-time-calculator'; @@ -31,6 +32,7 @@ export class GameServer extends CommandReceiver { private declaPoints = 0; private redPoints = 0; + private matchPointAnnounced: Partial> = {}; private isInEndGame = false; private timeScaling = 1; @@ -52,6 +54,7 @@ export class GameServer extends CommandReceiver { this.deltaTimes = []; this.declaPoints = 0; this.redPoints = 0; + this.matchPointAnnounced = {}; this.isInEndGame = false; this.timeScaling = 1; previousPlayers?.queueCommandForEachClient(new GameStartCommand()); @@ -126,6 +129,23 @@ export class GameServer extends CommandReceiver { this.endGame(CharacterTeam.decla); } else if (this.redPoints >= this.options.scoreLimit) { this.endGame(CharacterTeam.red); + } else { + this.announceMatchPointOnce(CharacterTeam.decla, this.declaPoints); + this.announceMatchPointOnce(CharacterTeam.red, this.redPoints); + } + } + + private announceMatchPointOnce(team: CharacterTeam, points: number) { + if ( + !this.matchPointAnnounced[team] && + points >= this.options.scoreLimit * settings.matchPointScoreRatio + ) { + this.matchPointAnnounced[team] = true; + this.players.queueCommandForEachClient( + new ServerAnnouncement( + `Match point — team ${team}!`, + ), + ); } } diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index 21ac869..bd46282 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -12,6 +12,8 @@ import { UpdatePropertyCommand, CommandExecutors, CommandReceiver, + mix, + clamp01, } from 'shared'; import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { CirclePhysical } from './circle-physical'; @@ -35,8 +37,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical private static readonly feetRadius = 20; private projectileStrength = settings.playerMaxStrength; - // offsets are measured from (0, 0) - private static readonly desiredHeadOffset = vec2.fromValues(0, 65); + // offsets are measured from (0, 0). The head sits this far above the feet, + // which sets the leg length: kept short so the body reads as a compact head on + // stubby legs rather than a long-legged strider. + private static readonly desiredHeadOffset = vec2.fromValues(0, 55); private static readonly desiredLeftFootOffset = vec2.fromValues(-20, 0); private static readonly desiredRightFootOffset = vec2.fromValues(20, 0); private static readonly centerOfMass = vec2.scale( @@ -76,15 +80,21 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical private isDestroyed = false; private timeSinceBorn = 0; private hasJustBorn = true; + private timeAlive = 0; + + private timeSinceLastShot = settings.projectileCreationInterval; + private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds; + private lastSyncedHealth = settings.playerMaxHealth; + + private killStreak = 0; private direction = 0; private currentPlanet?: PlanetPhysical; - private secondsSinceOnSurface = 1000; + private secondsSinceOnSurface = settings.planetDetachmentSeconds; public head: CirclePhysical; public leftFoot: CirclePhysical; public rightFoot: CirclePhysical; - public bound: CirclePhysical; private movementActions: Array = []; private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create()); @@ -128,12 +138,12 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical container.addObject(this.head); container.addObject(this.leftFoot); container.addObject(this.rightFoot); + } - this.bound = new CirclePhysical( - vec2.create(), - CharacterPhysical.boundRadius, - this, - container, + private get isSpawnProtected(): boolean { + return ( + this.timeAlive < + settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds ); } @@ -156,9 +166,33 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.movementActions.push(c); } - public addKill() { + public get groundPlanet(): PlanetPhysical | undefined { + return this.currentPlanet; + } + + public addKill(victimName: string) { this.killCount++; + this.killStreak++; this.remoteCall('setKillCount', this.killCount); + + this.health = Math.min( + settings.playerMaxHealth, + this.health + settings.playerKillHealthReward, + ); + this.syncHealth(); + this.remoteCall('onKillConfirmed', victimName, this.killStreak); + } + + public registerHit() { + this.remoteCall('onHitConfirmed'); + } + + private syncHealth() { + const rounded = Math.round(this.health); + if (rounded !== this.lastSyncedHealth) { + this.lastSyncedHealth = rounded; + this.remoteCall('setHealth', this.health); + } } public onCollision({ other }: ReactToCollisionCommand) { @@ -168,28 +202,54 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical other.isAlive ) { other.destroy(); + + if (this.isSpawnProtected) { + return; + } + + this.timeSinceLastDamage = 0; this.health -= other.strength; + this.lastSyncedHealth = Math.round(this.health); this.remoteCall('setHealth', this.health); + if (this.health <= 0 && this.isAlive) { this.onDie(); - other.originator.addKill(); + other.originator.addKill(this.name); + } else { + other.originator.registerHit(); } } } - public shootTowards(position: vec2) { - if (!this.isAlive) { + public shootTowards(position: vec2, charge = 0) { + if ( + !this.isAlive || + this.timeSinceLastShot < settings.projectileCreationInterval || + this.projectileStrength < settings.chargeShotStrengthMin + ) { return; } + this.timeSinceLastShot = 0; + + const c = clamp01(charge); + const desiredStrength = mix( + settings.chargeShotStrengthMin, + settings.chargeShotStrengthMax, + c, + ); + const strength = Math.min(desiredStrength, this.projectileStrength); + this.projectileStrength -= strength; + + const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c); + const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c); + const direction = vec2.subtract(vec2.create(), position, this.center); vec2.normalize(direction, direction); - const velocity = vec2.scale(direction, direction, settings.projectileSpeed); - const strength = this.projectileStrength / 2; - this.projectileStrength -= strength; + const velocity = vec2.scale(direction, direction, speed); const projectile = new ProjectilePhysical( vec2.clone(this.center), - 20, + radius, strength, this.team, velocity, @@ -202,8 +262,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } public get boundingBox(): BoundingBoxBase { - this.bound.center = this.head.center; - return this.bound.boundingBox; + return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius)); } public get gameObject(): this { @@ -257,6 +316,11 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical new UpdatePropertyCommand('head', this.head, this.headVelocity), new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity), new UpdatePropertyCommand('rightFoot', this.rightFoot, this.rightFootVelocity), + new UpdatePropertyCommand( + 'strength', + this.projectileStrength, + settings.playerStrengthRegenerationPerSeconds, + ), ]); } @@ -298,6 +362,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical private step({ deltaTimeInSeconds, game }: StepCommand) { this.getPoints(game); + this.timeAlive += deltaTimeInSeconds; const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius); const oldLeftFoot = new Circle( vec2.clone(this.leftFoot.center), @@ -328,16 +393,23 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical return; } - if ((this.secondsSinceOnSurface += deltaTimeInSeconds) > 0.5) { + if ( + (this.secondsSinceOnSurface += deltaTimeInSeconds) > + settings.planetDetachmentSeconds + ) { this.currentPlanet = undefined; } + this.timeSinceLastShot += deltaTimeInSeconds; + this.projectileStrength = Math.min( settings.playerMaxStrength, this.projectileStrength + - settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, + settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, ); + this.regenerateHealth(deltaTimeInSeconds); + this.currentPlanet?.takeControl(this.team, deltaTimeInSeconds); const intersectingWithForceField = this.container.findIntersecting( @@ -351,8 +423,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical const direction = this.averageAndResetMovementActions(); const movementForce = vec2.scale(direction, direction, settings.maxAcceleration); - this.applyForce(this.leftFoot, movementForce, deltaTimeInSeconds); - this.applyForce(this.rightFoot, movementForce, deltaTimeInSeconds); + this.leftFoot.applyForce(movementForce, deltaTimeInSeconds); + this.rightFoot.applyForce(movementForce, deltaTimeInSeconds); if (!this.currentPlanet) { const leftFootGravity = forceAtPosition( @@ -364,8 +436,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical intersectingWithForceField, ); - this.applyForce(this.leftFoot, leftFootGravity, deltaTimeInSeconds); - this.applyForce(this.rightFoot, rightFootGravity, deltaTimeInSeconds); + this.leftFoot.applyForce(leftFootGravity, deltaTimeInSeconds); + this.rightFoot.applyForce(rightFootGravity, deltaTimeInSeconds); const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce); @@ -377,8 +449,11 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical vec2.add(leftFootGravity, leftFootGravity, rightFootGravity); const gravity = vec2.scale(leftFootGravity, leftFootGravity, 0.5); - if (vec2.dot(movementForce, gravity) < -vec2.length(movementForce) * 0.8) { - vec2.scale(gravity, gravity, 0.35); + if ( + vec2.dot(movementForce, gravity) < + -vec2.length(movementForce) * settings.climbDotThreshold + ) { + vec2.scale(gravity, gravity, settings.climbGravityScale); } const scaledLeftFootGravity = vec2.scale( @@ -386,7 +461,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical this.leftFoot.lastNormal, vec2.dot(this.leftFoot.lastNormal, gravity), ); - this.applyForce(this.leftFoot, scaledLeftFootGravity, deltaTimeInSeconds); + this.leftFoot.applyForce(scaledLeftFootGravity, deltaTimeInSeconds); const scaledRightFootGravity = vec2.scale( vec2.create(), @@ -394,15 +469,15 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical vec2.dot(this.rightFoot.lastNormal, gravity), ); - this.applyForce(this.rightFoot, scaledRightFootGravity, deltaTimeInSeconds); + this.rightFoot.applyForce(scaledRightFootGravity, deltaTimeInSeconds); - if (vec2.length(gravity) <= 100) { + if (vec2.length(gravity) <= settings.planetDetachmentForceThreshold) { this.currentPlanet = undefined; } this.setDirection(gravity); } - this.keepPosture(deltaTimeInSeconds); + this.keepPosture(); this.stepBodyPart(this.leftFoot, deltaTimeInSeconds); this.stepBodyPart(this.rightFoot, deltaTimeInSeconds); this.stepBodyPart(this.head, deltaTimeInSeconds); @@ -418,56 +493,57 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical ); } - private keepPosture(deltaTime: number) { + private keepPosture() { const center = this.center; this.springMove( this.leftFoot, center, CharacterPhysical.leftFootOffset, - deltaTime, - 3000, + settings.postureFeetStiffness, ); this.springMove( this.rightFoot, center, CharacterPhysical.rightFootOffset, - deltaTime, - 3000, + settings.postureFeetStiffness, ); - this.springMove(this.head, center, CharacterPhysical.headOffset, deltaTime, 7000); + this.springMove( + this.head, + center, + CharacterPhysical.headOffset, + settings.postureHeadStiffness, + ); } private springMove( object: CirclePhysical, center: vec2, offset: vec2, - deltaTime: number, - strength: number, + stiffness: number, ) { const desiredPosition = vec2.add(vec2.create(), center, offset); vec2.rotate(desiredPosition, desiredPosition, center, this.direction); const positionDelta = vec2.subtract(vec2.create(), desiredPosition, object.center); - const positionDeltaLength = vec2.length(positionDelta); + // First-order velocity relaxation toward the desired posture position, + // added to (not replacing) the gravity/movement velocity accumulated + // earlier this tick. stepManually applies it over deltaTime, so the + // per-tick displacement is positionDelta * stiffness * deltaTime. + vec2.scaleAndAdd(object.velocity, object.velocity, positionDelta, stiffness); + } - if (positionDeltaLength > 0) { - const positionDeltaDirection = vec2.normalize(vec2.create(), positionDelta); - vec2.scale( - positionDelta, - positionDeltaDirection, - positionDeltaLength ** 2 * deltaTime * strength, + private regenerateHealth(deltaTimeInSeconds: number) { + this.timeSinceLastDamage += deltaTimeInSeconds; + if ( + this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds && + this.health < settings.playerMaxHealth + ) { + this.health = Math.min( + settings.playerMaxHealth, + this.health + settings.playerHealthRegenerationPerSeconds * deltaTimeInSeconds, ); - - if (vec2.length(positionDelta) * deltaTime * deltaTime > positionDeltaLength) { - vec2.scale( - positionDelta, - positionDelta, - positionDeltaLength / (vec2.length(positionDelta) * deltaTime * deltaTime), - ); - } - - object.applyForce(positionDelta, deltaTime); + this.syncHealth(); } } @@ -479,14 +555,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical } } - public applyForce(circle: CirclePhysical, force: vec2, timeInSeconds: number) { - vec2.add( - circle.velocity, - circle.velocity, - vec2.scale(vec2.create(), force, timeInSeconds), - ); - } - public onDie() { this.isDestroyed = true; this.remoteCall('onDie'); diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts index 5da432b..9e7b094 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -4,6 +4,7 @@ import { clamp, clamp01, id, + mix, serializesTo, settings, PlanetBase, @@ -18,20 +19,38 @@ import { StepCommand } from '../commands/step'; import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; import { StaticPhysical } from '../physics/physicals/static-physical'; +import { LampPhysical } from './lamp-physical'; @serializesTo(PlanetBase) export class PlanetPhysical extends PlanetBase implements StaticPhysical { public readonly canCollide = true; public readonly canMove = false; + public readonly sizePointMultiplier: number; + private _boundingBox?: ImmutableBoundingBox; + private readonly lamps: Array = []; + + private lastTeam: CharacterTeam = CharacterTeam.neutral; + protected commandExecutors: CommandExecutors = { [StepCommand.type]: this.step.bind(this), }; + public addLamp(lamp: LampPhysical) { + this.lamps.push(lamp); + } + constructor(vertices: Array) { super(id(), vertices); + + const sizeClass = clamp01( + (this.radius - settings.planetMinReferenceRadius) / + (settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius), + ); + + this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass); } public distance(target: vec2): number { @@ -80,14 +99,14 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { private getPoints(game: CommandReceiver) { if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) { this.timeSinceLastPointGeneration = 0; - if (this.team !== CharacterTeam.neutral) { - this.remoteCall('generatedPoints', settings.planetPointGenerationValue); - } + const value = Math.round( + settings.planetPointGenerationValue * this.sizePointMultiplier, + ); game.handleCommand( new GeneratePointsCommand( - this.team === CharacterTeam.decla ? settings.planetPointGenerationValue : 0, - this.team === CharacterTeam.red ? settings.planetPointGenerationValue : 0, + this.team === CharacterTeam.decla ? value : 0, + this.team === CharacterTeam.red ? value : 0, ), ); } @@ -95,9 +114,40 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { private step({ deltaTimeInSeconds, game }: StepCommand) { this.timeSinceLastPointGeneration += deltaTimeInSeconds; + // In reverse order, so that teams can achieve a 100% control. this.getPoints(game); this.takeControl(CharacterTeam.neutral, deltaTimeInSeconds); + this.detectFlip(game); + } + + private detectFlip(game: CommandReceiver) { + const currentTeam = this.team; + if (currentTeam === this.lastTeam) { + return; + } + this.lastTeam = currentTeam; + + if (currentTeam !== CharacterTeam.neutral) { + const reward = Math.round( + settings.captureFlipPointReward * this.sizePointMultiplier, + ); + this.remoteCall('generatedPoints', reward); + game.handleCommand( + new GeneratePointsCommand( + currentTeam === CharacterTeam.decla ? reward : 0, + currentTeam === CharacterTeam.red ? reward : 0, + ), + ); + } + + const control = Math.abs(this.ownership - 0.5) / 0.5; + const lightness = mix(settings.lampMinLightness, settings.lampMaxLightness, control); + const color = settings.palette[settings.colorIndices[currentTeam]]; + + this.lamps.forEach((lamp) => lamp.queueSetLight(color, lightness)); + + this.remoteCall('onFlipped', currentTeam); } public getPropertyUpdates(): PropertyUpdatesForObject { @@ -153,7 +203,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { public getForce(position: vec2): vec2 { const diff = vec2.subtract(vec2.create(), this.center, position); - const dist = Math.max(0, vec2.length(diff) - this.radius); + const dist = Math.max(settings.minGravityDistance, vec2.length(diff) - this.radius); vec2.normalize(diff, diff); const scale = clamp( settings.maxGravityQ * ((settings.maxGravityDistance / dist) ** 1.5 - 1), diff --git a/backend/src/players/npc.ts b/backend/src/players/npc.ts index e9075c0..bd2dee9 100644 --- a/backend/src/players/npc.ts +++ b/backend/src/players/npc.ts @@ -6,6 +6,7 @@ import { Random, MoveActionCommand, CharacterTeam, + Id, } from 'shared'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { PlayerContainer } from './player-container'; @@ -13,16 +14,77 @@ import { PlayerBase } from './player-base'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { CharacterPhysical } from '../objects/character-physical'; import { PlanetPhysical } from '../objects/planet-physical'; +import { ProjectilePhysical } from '../objects/projectile-physical'; import { Physical } from '../physics/physicals/physical'; +const npcTuning = { + planIntervalSeconds: 1, + shootIntervalSeconds: 1.5, + reactionIntervalSeconds: 1 / 10, + reactionObserveRadius: 1400, + planScanRadius: 3500, + + aggressionMin: 0.25, + aggressionMax: 1, + + wanderReconsiderSeconds: 5, + wanderProbability: 0.4, + wanderTurn: 0.3, + + fleeBaseRange: 240, + fleeAggressionFalloff: 1.4, + + chaseBaseRange: 700, + chaseAggressionRange: 1600, + captureHoldEnemyDistance: 500, + + dodgeThreatRange: 450, + dodgeApproachDot: 0.6, + + dodgeBaseChance: 0.25, + dodgeAggressionChance: 0.35, + dodgeCommitSeconds: 0.35, + dodgeCooldownSeconds: 0.5, + + lineOfSightClearance: 20, + lineOfSightStartOffset: 100, + + fireBaseChance: 0.45, + fireAggressionChance: 0.45, + + chargeRangeThreshold: 500, + chargeBaseChance: 0.3, + chargeAggressionChance: 0.4, + chargeMin: 0.6, + + spreadBase: 60, + spreadPerDistance: 0.08, + spreadAggressionFalloff: 1.3, +}; + export class NPC extends PlayerBase { - private direction: vec2 = vec2.fromValues(Random.getRandom(), Random.getRandom()); - private timeSinceLastFindTarget = 10000; - private timeSinceLastFindShootTarget = 10000; + private direction = vec2.fromValues(Random.getRandom() - 0.5, Random.getRandom() - 0.5); + private timeSinceLastPlan = 10000; + private timeSinceLastShoot = 10000; private isWandering = false; private timeSinceLastWanderingConsideration = 0; private isComingBack = false; + private readonly aggression = Random.getRandomInRange( + npcTuning.aggressionMin, + npcTuning.aggressionMax, + ); + + private aimTargetId: Id = null; + private readonly aimTargetLastPosition = vec2.create(); + + private readonly dodgeDirection = vec2.create(); + private dodgeCommitRemaining = 0; + private dodgeCooldownRemaining = 0; + + private timeSinceObserve = npcTuning.reactionIntervalSeconds; + private nearObjects: Array = []; + constructor( playerInfo: PlayerInformation, playerContainer: PlayerContainer, @@ -34,172 +96,304 @@ export class NPC extends PlayerBase { this.step(0); } - private findTarget() { - if ( - (!this.isComingBack && vec2.length(this.center) > settings.worldRadius) || - (this.isComingBack && vec2.length(this.center) > settings.worldRadius / 2) - ) { - this.isComingBack = true; - vec2.subtract(this.direction, vec2.fromValues(0, 0), this.center); - return; - } - - this.isComingBack = false; - - const observableArea = getBoundingBoxOfCircle(new Circle(this.center, 2000)); - const nearObjects = this.objectContainer.findIntersecting(observableArea); - const characters = this.findNearCharactersSorted(nearObjects); - - if (characters.length > 0) { - const nearest = characters[0]; - if (nearest.distance < 200) { - vec2.subtract(this.direction, this.center, nearest.character.center); - return; - } - } - - const enemies = characters.filter((o) => o.character.team !== this.team); - - if (enemies.length > 0) { - const nearest = enemies[0]; - if (nearest.distance < 500) { - vec2.subtract(this.direction, this.center, nearest.character.center); - return; - } - } - - if (enemies.length > 0) { - const nearest = enemies[0]; - if (nearest.distance > 1000) { - vec2.subtract(this.direction, nearest.character.center, this.center); - return; - } - } - - if (this.isWandering) { - vec2.rotate( - this.direction, - this.direction, - vec2.create(), - Random.getRandomInRange(-0.2, 0.2), - ); - } else { - const planets = this.findNearPlanetsSorted(nearObjects).filter( - (p) => p.planet.team !== this.team, - ); - - if (planets.length > 0) { - vec2.subtract(this.direction, planets[0].planet.center, this.center); - } else { - this.isWandering = true; - } - } - } - - private findNearCharactersSorted( - nearObjects: Array, - ): Array<{ character: CharacterPhysical; distance: number }> { - const characters = Array.from( - new Set( - nearObjects.filter( - (o) => - o.gameObject instanceof CharacterPhysical && o.gameObject !== this.character, - ), - ), - ).map((c) => ({ - character: c.gameObject, - distance: vec2.distance(this.center, (c.gameObject as CharacterPhysical).center), - })) as Array<{ character: CharacterPhysical; distance: number }>; - - characters.sort((a, b) => a.distance - b.distance); - - return characters; - } - - private findNearPlanetsSorted( - nearObjects: Array, - ): Array<{ planet: PlanetPhysical; distance: number }> { - const planets = nearObjects - .filter((o) => o.gameObject instanceof PlanetPhysical) - .map((c) => ({ - planet: c.gameObject, - distance: vec2.distance(this.center, (c.gameObject as PlanetPhysical).center), - })) as Array<{ planet: PlanetPhysical; distance: number }>; - - planets.sort((a, b) => a.distance - b.distance); - return planets; - } - - private findShootTarget(): vec2 | undefined { - const observableArea = getBoundingBoxOfCircle(new Circle(this.center, 1000)); - const nearObjects = this.objectContainer.findIntersecting(observableArea); - - const enemies = nearObjects.filter( - (o) => o.gameObject instanceof CharacterPhysical && o.gameObject.team !== this.team, - ); - if (enemies.length > 0) { - return (enemies[0].gameObject as CharacterPhysical).center; - } - } - private timeUntilRespawn = 0; public step(deltaTimeInSeconds: number) { if (this.character) { - this.center = this.character?.center; + this.center = this.character.center; if (!this.character.isAlive) { this.sumDeaths++; this.sumKills = this.character.killCount; - this.character = null; this.timeUntilRespawn = settings.playerDiedTimeout; + return; } } else { if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { this.createCharacter(); this.center = this.character!.center; } + return; } - if ((this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) > 3) { + if ( + (this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) > + npcTuning.wanderReconsiderSeconds + ) { this.timeSinceLastWanderingConsideration = 0; - this.isWandering = Random.getRandom() > 0.5; + this.isWandering = Random.getRandom() < npcTuning.wanderProbability; } - if ((this.timeSinceLastFindTarget += deltaTimeInSeconds) > 1) { - this.timeSinceLastFindTarget = 0; - this.findTarget(); + if ((this.timeSinceLastPlan += deltaTimeInSeconds) > npcTuning.planIntervalSeconds) { + this.timeSinceLastPlan = 0; + this.plan(); } - if ((this.timeSinceLastFindShootTarget += deltaTimeInSeconds) > 0.5) { - if (Random.getRandom() > 0.5) { - const shootTarget = this.findShootTarget(); - if (shootTarget) { - vec2.add( - shootTarget, - shootTarget, - vec2.fromValues( - Random.getRandomInRange(-200, 200), - Random.getRandomInRange(-200, 200), - ), - ); - this.character?.shootTowards(shootTarget); - } - } - - this.timeSinceLastFindShootTarget = 0; + if ( + (this.timeSinceObserve += deltaTimeInSeconds) > npcTuning.reactionIntervalSeconds + ) { + this.timeSinceObserve = 0; + this.nearObjects = this.observe(npcTuning.reactionObserveRadius); } - this.character?.handleMovementAction(new MoveActionCommand(this.direction)); + this.dodgeCommitRemaining -= deltaTimeInSeconds; + this.dodgeCooldownRemaining -= deltaTimeInSeconds; + const movement = this.decideMovement(this.nearObjects); + this.character.handleMovementAction(new MoveActionCommand(movement)); + + if ( + (this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds + ) { + this.timeSinceLastShoot = 0; + this.tryShoot(this.nearObjects); + } } - protected createCharacter() { - const randomPoint = vec2.rotate( + private plan() { + const distanceFromCentre = vec2.length(this.center); + if ( + (!this.isComingBack && distanceFromCentre > settings.worldRadius) || + (this.isComingBack && distanceFromCentre > settings.worldRadius / 2) + ) { + this.isComingBack = true; + vec2.negate(this.direction, this.center); + return; + } + this.isComingBack = false; + + const nearObjects = this.observe(npcTuning.planScanRadius); + const enemies = this.enemiesByDistance(nearObjects); + + if (enemies.length > 0) { + const nearest = enemies[0]; + const fleeRange = + npcTuning.fleeBaseRange * (npcTuning.fleeAggressionFalloff - this.aggression); + if (nearest.distance < fleeRange) { + vec2.subtract(this.direction, this.center, nearest.character.center); + return; + } + } + + if (enemies.length > 0) { + const nearest = enemies[0]; + const chaseRange = + npcTuning.chaseBaseRange + npcTuning.chaseAggressionRange * this.aggression; + if (nearest.distance < chaseRange) { + vec2.subtract(this.direction, nearest.character.center, this.center); + return; + } + } + + if (!this.isWandering) { + const planet = this.capturablePlanetsByDistance(nearObjects)[0]; + if (planet) { + vec2.subtract(this.direction, planet.planet.center, this.center); + return; + } + } + + vec2.rotate( + this.direction, + this.direction, vec2.create(), - vec2.fromValues(Random.getRandomInRange(0, settings.worldRadius), 0), - vec2.create(), - Random.getRandomInRange(0, Math.PI * 2), + Random.getRandomInRange(-npcTuning.wanderTurn, npcTuning.wanderTurn), ); - super.createCharacter(randomPoint); + } + + private decideMovement(nearObjects: Array): vec2 { + if (this.dodgeCommitRemaining > 0) { + return vec2.clone(this.dodgeDirection); + } + if (this.dodgeCooldownRemaining <= 0) { + const dodge = this.dodgeVector(nearObjects); + if (dodge) { + if ( + Random.getRandom() < + npcTuning.dodgeBaseChance + npcTuning.dodgeAggressionChance * this.aggression + ) { + vec2.copy(this.dodgeDirection, dodge); + this.dodgeCommitRemaining = npcTuning.dodgeCommitSeconds; + return vec2.clone(this.dodgeDirection); + } + this.dodgeCooldownRemaining = npcTuning.dodgeCooldownSeconds; + } + } + + const planet = this.character!.groundPlanet; + if (planet && planet.team !== this.team) { + const enemies = this.enemiesByDistance(nearObjects); + if ( + enemies.length === 0 || + enemies[0].distance > npcTuning.captureHoldEnemyDistance + ) { + return vec2.create(); + } + } + + return vec2.normalize(vec2.create(), this.direction); + } + + private dodgeVector(nearObjects: Array): vec2 | undefined { + let threat: ProjectilePhysical | undefined; + let threatDistance = Infinity; + + for (const o of nearObjects) { + const p = o.gameObject; + if (!(p instanceof ProjectilePhysical) || p.team === this.team || !p.isAlive) { + continue; + } + const toMe = vec2.subtract(vec2.create(), this.center, p.center); + const distance = vec2.length(toMe); + if (distance > npcTuning.dodgeThreatRange || distance === 0) { + continue; + } + vec2.normalize(toMe, toMe); + if ( + vec2.dot(p.direction, toMe) > npcTuning.dodgeApproachDot && + distance < threatDistance + ) { + threatDistance = distance; + threat = p; + } + } + + if (!threat) { + return undefined; + } + + const perpendicular = vec2.fromValues(-threat.direction.y, threat.direction.x); + const toMe = vec2.subtract(vec2.create(), this.center, threat.center); + if (vec2.dot(perpendicular, toMe) < 0) { + vec2.negate(perpendicular, perpendicular); + } + vec2.normalize(perpendicular, perpendicular); + + return perpendicular; + } + + private tryShoot(nearObjects: Array) { + const enemies = this.enemiesByDistance(nearObjects); + const visible = enemies.find((e) => + this.hasLineOfSightTo(e.character.center, nearObjects), + ); + if (!visible) { + this.aimTargetId = null; + return; + } + + const target = visible.character; + const distance = visible.distance; + + const velocity = vec2.create(); + if (this.aimTargetId === target.id) { + vec2.subtract(velocity, target.center, this.aimTargetLastPosition); + vec2.scale(velocity, velocity, 1 / npcTuning.shootIntervalSeconds); + } + this.aimTargetId = target.id; + vec2.copy(this.aimTargetLastPosition, target.center); + + if ( + Random.getRandom() > + npcTuning.fireBaseChance + npcTuning.fireAggressionChance * this.aggression + ) { + return; + } + + const charge = + distance > npcTuning.chargeRangeThreshold && + Random.getRandom() < + npcTuning.chargeBaseChance + npcTuning.chargeAggressionChance * this.aggression + ? Random.getRandomInRange(npcTuning.chargeMin, 1) + : 0; + + const projectileSpeed = + charge > 0 ? settings.chargeShotSpeedMax : settings.chargeShotSpeedMin; + const leadTime = distance / projectileSpeed; + const aim = vec2.scaleAndAdd(vec2.create(), target.center, velocity, leadTime); + + (npcTuning.spreadBase + distance * npcTuning.spreadPerDistance) * + (npcTuning.spreadAggressionFalloff - this.aggression); + aim.x += Random.getRandomInRange(-spread, spread); + aim.y += Random.getRandomInRange(-spread, spread); + + this.character!.shootTowards(aim, charge); + } + + private hasLineOfSightTo(target: vec2, nearObjects: Array): boolean { + const planets: Array = []; + for (const o of nearObjects) { + if (o.gameObject instanceof PlanetPhysical) { + planets.push(o.gameObject); + } + } + if (planets.length === 0) { + return true; + } + + const direction = vec2.subtract(vec2.create(), target, this.center); + const totalDistance = vec2.length(direction); + if (totalDistance <= npcTuning.lineOfSightStartOffset) { + return true; + } + vec2.normalize(direction, direction); + + let traveled = npcTuning.lineOfSightStartOffset; + const position = vec2.scaleAndAdd(vec2.create(), this.center, direction, traveled); + while (traveled < totalDistance) { + let sdf = Infinity; + for (const planet of planets) { + sdf = Math.min(sdf, planet.distance(position)); + } + if (sdf < npcTuning.lineOfSightClearance) { + return false; + } + traveled += sdf; + vec2.scaleAndAdd(position, position, direction, sdf); + } + return true; + } + + private observe(radius: number): Array { + return this.objectContainer.findIntersecting( + getBoundingBoxOfCircle(new Circle(this.center, radius)), + ); + } + + private enemiesByDistance( + nearObjects: Array, + ): Array<{ character: CharacterPhysical; distance: number }> { + const seen = new Set(); + const enemies: Array<{ character: CharacterPhysical; distance: number }> = []; + for (const o of nearObjects) { + const c = o.gameObject; + if ( + c instanceof CharacterPhysical && + c !== this.character && + c.isAlive && + c.team !== this.team && + !seen.has(c) + ) { + seen.add(c); + enemies.push({ character: c, distance: vec2.distance(this.center, c.center) }); + } + } + enemies.sort((a, b) => a.distance - b.distance); + return enemies; + } + + private capturablePlanetsByDistance( + nearObjects: Array, + ): Array<{ planet: PlanetPhysical; distance: number }> { + const planets = nearObjects + .filter( + (o): o is Physical => + o.gameObject instanceof PlanetPhysical && o.gameObject.team !== this.team, + ) + .map((o) => ({ + planet: o.gameObject as PlanetPhysical, + distance: vec2.distance(this.center, (o.gameObject as PlanetPhysical).center), + })); + planets.sort((a, b) => a.distance - b.distance); + return planets; } } diff --git a/frontend/src/scripts/commands/mouse-listener.ts b/frontend/src/scripts/commands/mouse-listener.ts index 23f2bd9..c4bd30d 100644 --- a/frontend/src/scripts/commands/mouse-listener.ts +++ b/frontend/src/scripts/commands/mouse-listener.ts @@ -1,8 +1,14 @@ import { vec2 } from 'gl-matrix'; -import { CommandGenerator, PrimaryActionCommand, SecondaryActionCommand } from 'shared'; +import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared'; import { Game } from '../game'; +import { ChargeIndicator } from '../charge-indicator'; +import { Pointer } from '../helper/pointer'; export class MouseListener extends CommandGenerator { + // Timestamp (ms) of the primary press, or null when not held. On release the + // held duration is mapped to the charge scalar; a quick tap reads as ~0. + private primaryDownAt: number | null = null; + constructor( private target: HTMLElement, private readonly game: Game, @@ -10,22 +16,43 @@ export class MouseListener extends CommandGenerator { super(); target.addEventListener('mousedown', this.mouseDownListener); + target.addEventListener('mouseup', this.mouseUpListener); + target.addEventListener('mousemove', this.mouseMoveListener); target.addEventListener('contextmenu', this.contextMenuListener); } - private mouseDownListener = (event: MouseEvent) => { - const position = this.positionFromEvent(event); + // Only the screen position is stored; it is reprojected to world space each + // frame so the gaze stays correct even while the camera pans under a still + // cursor. + private mouseMoveListener = (event: MouseEvent) => { + Pointer.setDisplayPosition(event.clientX, event.clientY); + }; + private mouseDownListener = (event: MouseEvent) => { if (event.button === 0) { - this.sendCommandToSubscribers(new PrimaryActionCommand(position)); + this.primaryDownAt = performance.now(); + // The ring follows the cursor and only fades in once this press has + // clearly become a hold. + ChargeIndicator.begin(event.clientX, event.clientY, true); } }; + private mouseUpListener = (event: MouseEvent) => { + if (event.button !== 0 || this.primaryDownAt === null) { + return; + } + + ChargeIndicator.end(); + const charge = holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000); + this.primaryDownAt = null; + this.sendCommandToSubscribers( + new PrimaryActionCommand(this.positionFromEvent(event), charge), + ); + }; + + // Suppress the browser context menu on the canvas; right-click has no action. private contextMenuListener = (event: MouseEvent) => { event.preventDefault(); - - const position = this.positionFromEvent(event); - this.sendCommandToSubscribers(new SecondaryActionCommand(position)); }; private positionFromEvent(event: MouseEvent): vec2 { @@ -35,7 +62,10 @@ export class MouseListener extends CommandGenerator { } public destroy() { + ChargeIndicator.end(); this.target.removeEventListener('mousedown', this.mouseDownListener); + this.target.removeEventListener('mouseup', this.mouseUpListener); + this.target.removeEventListener('mousemove', this.mouseMoveListener); this.target.removeEventListener('contextmenu', this.contextMenuListener); } } diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts index 7f69f61..4ecb6bf 100644 --- a/frontend/src/scripts/commands/touch-listener.ts +++ b/frontend/src/scripts/commands/touch-listener.ts @@ -1,6 +1,14 @@ import { vec2 } from 'gl-matrix'; -import { CommandGenerator, MoveActionCommand, last, PrimaryActionCommand } from 'shared'; +import { + CommandGenerator, + MoveActionCommand, + last, + PrimaryActionCommand, + holdDurationToCharge, + settings, +} from 'shared'; import { Game } from '../game'; +import { ChargeIndicator } from '../charge-indicator'; export class TouchListener extends CommandGenerator { private static readonly deadZone = 8; @@ -10,6 +18,12 @@ export class TouchListener extends CommandGenerator { private joystickButton: HTMLElement; private isJoystickActive = false; private touchStartPosition!: vec2; + private primaryDownAt: number | null = null; + + private fireButton: HTMLElement; + private fireStrengthRing: HTMLElement; + + private fireDownAt: number | null = null; constructor( private target: HTMLElement, @@ -23,6 +37,17 @@ export class TouchListener extends CommandGenerator { this.joystickButton = document.createElement('div'); this.joystick.appendChild(this.joystickButton); + this.fireButton = document.createElement('div'); + this.fireButton.className = 'touch-button fire'; + this.fireStrengthRing = document.createElement('div'); + this.fireStrengthRing.className = 'strength-ring'; + this.fireButton.appendChild(this.fireStrengthRing); + + this.fireButton.addEventListener('touchstart', this.fireButtonDownListener); + this.fireButton.addEventListener('touchend', this.fireButtonUpListener); + + this.overlay.appendChild(this.fireButton); + target.addEventListener('touchstart', this.touchStartListener); target.addEventListener('touchmove', this.touchMoveListener); target.addEventListener('touchend', this.touchEndListener); @@ -43,6 +68,8 @@ export class TouchListener extends CommandGenerator { event.touches[0].clientX, event.touches[0].clientY, ); + this.primaryDownAt = performance.now(); + ChargeIndicator.begin(this.touchStartPosition.x, this.touchStartPosition.y); } }; @@ -60,6 +87,8 @@ export class TouchListener extends CommandGenerator { if (!this.isJoystickActive && deltaLength > TouchListener.deadZone) { this.isJoystickActive = true; + this.primaryDownAt = null; + ChargeIndicator.end(); this.overlay.appendChild(this.joystick); this.joystickButton.style.transform = `translateX(-50%) translateY(-50%)`; this.joystick.style.transform = `translateX(${this.touchStartPosition.x}px) translateY(${this.touchStartPosition.y}px) translateX(-50%) translateY(-50%)`; @@ -71,7 +100,8 @@ export class TouchListener extends CommandGenerator { vec2.set(delta, delta.x, -delta.y); if (deltaLength > TouchListener.deadZone) { - this.sendCommandToSubscribers(new MoveActionCommand(vec2.normalize(delta, delta))); + const direction = vec2.normalize(delta, delta); + this.sendCommandToSubscribers(new MoveActionCommand(direction)); } else { this.sendCommandToSubscribers(new MoveActionCommand(vec2.create())); } @@ -81,12 +111,18 @@ export class TouchListener extends CommandGenerator { event.preventDefault(); if (!this.isJoystickActive) { + ChargeIndicator.end(); + const charge = + this.primaryDownAt === null + ? 0 + : holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000); + this.primaryDownAt = null; const center = vec2.fromValues( event.changedTouches[0].clientX, event.changedTouches[0].clientY, ); this.sendCommandToSubscribers( - new PrimaryActionCommand(this.game.displayToWorldCoordinates(center)), + new PrimaryActionCommand(this.game.displayToWorldCoordinates(center), charge), ); } else if (event.touches.length === 0) { this.isJoystickActive = false; @@ -95,9 +131,62 @@ export class TouchListener extends CommandGenerator { } }; + private swallowTouch = (event: TouchEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; + + private fireButtonDownListener = (event: TouchEvent) => { + this.swallowTouch(event); + this.fireDownAt = performance.now(); + const rect = this.fireButton.getBoundingClientRect(); + ChargeIndicator.begin(rect.left + rect.width / 2, rect.top + rect.height / 2); + }; + + private fireButtonUpListener = (event: TouchEvent) => { + this.swallowTouch(event); + ChargeIndicator.end(); + if (this.fireDownAt === null) { + return; + } + + const charge = holdDurationToCharge((performance.now() - this.fireDownAt) / 1000); + this.fireDownAt = null; + + const character = this.game.gameObjects.player; + if (!character) { + return; + } + const aim = vec2.scaleAndAdd( + vec2.create(), + character.bodyCenter, + character.facingDirection, + settings.touchAimRange, + ); + this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge)); + }; + + public update(_deltaTimeInSeconds: number) { + if (!this.fireButton.parentElement) { + this.overlay.appendChild(this.fireButton); + } + + const character = this.game.gameObjects.player; + if (character) { + this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${character.strengthFraction * 360 + }deg, transparent 0deg)`; + } + } + public destroy() { + ChargeIndicator.end(); this.target.removeEventListener('touchstart', this.touchStartListener); this.target.removeEventListener('touchmove', this.touchMoveListener); this.target.removeEventListener('touchend', this.touchEndListener); + + this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener); + this.fireButton.removeEventListener('touchend', this.fireButtonUpListener); + + this.fireButton.parentElement?.removeChild(this.fireButton); } } diff --git a/frontend/src/scripts/feedback-hud.ts b/frontend/src/scripts/feedback-hud.ts index bb08226..801828b 100644 --- a/frontend/src/scripts/feedback-hud.ts +++ b/frontend/src/scripts/feedback-hud.ts @@ -1,11 +1,6 @@ import { settings } from 'shared'; import { Pointer } from './helper/pointer'; -// Lightweight, self-managing combat-feedback overlay for the local player: -// hitmarkers, a personal killfeed, multi-kill callouts and a points popup. It -// owns a single fixed, click-through root under and auto-expires every -// element it creates, so callers (CharacterView remote-call handlers) just fire -// and forget — no wiring through the game loop. export abstract class FeedbackHud { private static root?: HTMLElement; private static killfeed?: HTMLElement; @@ -24,8 +19,6 @@ export abstract class FeedbackHud { return { root: this.root, killfeed: this.killfeed }; } - // Where transient marks/popups appear: the cursor on desktop, screen centre on - // touch (where the local character fires along its facing). private static focusPoint(): { x: number; y: number } { const cursor = Pointer.getDisplayPosition(); if (cursor) { @@ -40,7 +33,6 @@ export abstract class FeedbackHud { setTimeout(() => element.parentElement?.removeChild(element), lifetimeMs); } - // A non-lethal hit landed: a quick crosshair tick at the focus point. public static hitMarker() { const { x, y } = this.focusPoint(); const marker = document.createElement('div'); @@ -50,7 +42,6 @@ export abstract class FeedbackHud { this.addTransient(marker, 250); } - // A kill was confirmed: killfeed line, points popup and (for streaks) a callout. public static killConfirmed(victimName?: string, streak = 1) { const { killfeed } = this.ensureRoot(); diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index d09a99f..eb6b3dc 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -15,6 +15,7 @@ import { StepCommand } from '../commands/types/step'; import { Game } from '../game'; import { Camera } from './types/camera'; import { CharacterView } from './types/character-view'; +import { PlanetView } from './types/planet-view'; export class GameObjectContainer extends CommandReceiver { protected objects: Map = new Map(); @@ -57,6 +58,16 @@ export class GameObjectContainer extends CommandReceiver { super(); } + public get planets(): Array { + const planets: Array = []; + this.objects.forEach((o) => { + if (o instanceof PlanetView) { + planets.push(o); + } + }); + return planets; + } + protected defaultCommandExecutor(c: Command) { this.objects.forEach((o) => o.handleCommand(c)); this.camera.handleCommand(c); diff --git a/frontend/src/scripts/scoreboard.ts b/frontend/src/scripts/scoreboard.ts index 115d874..38dff4e 100644 --- a/frontend/src/scripts/scoreboard.ts +++ b/frontend/src/scripts/scoreboard.ts @@ -49,6 +49,11 @@ export class Scoreboard { 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)); diff --git a/frontend/src/scripts/shapes/character-shape.ts b/frontend/src/scripts/shapes/character-shape.ts new file mode 100644 index 0000000..711f70a --- /dev/null +++ b/frontend/src/scripts/shapes/character-shape.ts @@ -0,0 +1,219 @@ +import { mat2d, vec2 } from 'gl-matrix'; +import { Drawable, DrawableDescriptor } from 'sdf-2d'; +import { Circle } from 'shared'; + +// A single character silhouette shared by every team (teams differ only by +// colour): a circular head sitting on two thin line-legs that run down to the +// two feet. +export class CharacterShape extends Drawable { + public static descriptor: DrawableDescriptor = { + sdf: { + shader: ` + uniform vec2 characterHeadCenters[CHARACTER_COUNT]; + uniform vec2 characterLeftFeet[CHARACTER_COUNT]; + uniform vec2 characterRightFeet[CHARACTER_COUNT]; + uniform vec2 characterGazeTargets[CHARACTER_COUNT]; + uniform float characterHeadRadii[CHARACTER_COUNT]; + uniform float characterFootRadii[CHARACTER_COUNT]; + uniform int characterColors[CHARACTER_COUNT]; + uniform float characterFlash[CHARACTER_COUNT]; + + float circleDistance(vec2 circleCenter, float radius, vec2 target) { + return distance(target, circleCenter) - radius; + } + + // Distance to the segment a->b; subtract a radius to get a capsule (a + // rounded thick line). + float segmentDistance(vec2 a, vec2 b, vec2 target) { + vec2 pa = target - a; + vec2 ba = b - a; + float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); + return length(pa - ba * h); + } + + float characterMinDistance(vec2 target, out vec4 color) { + float minDistance = 1000.0; + + for (int i = 0; i < CHARACTER_COUNT; i++) { + vec2 head = characterHeadCenters[i]; + vec2 leftFoot = characterLeftFeet[i]; + vec2 rightFoot = characterRightFeet[i]; + float headRadius = characterHeadRadii[i]; + float footRadius = characterFootRadii[i]; + + // The server rotates the whole posture toward travel, so the head + // leads: forward is the facing direction, perp its 90deg rotation, so + // the face sits on the leading side and reads at any rotation. + vec2 footAverage = (leftFoot + rightFoot) * 0.5; + vec2 toHead = head - footAverage; + float toHeadLength = length(toHead); + vec2 forward = toHeadLength > 0.001 ? toHead / toHeadLength : vec2(0.0, 1.0); + vec2 perp = vec2(-forward.y, forward.x); + + // A circular head, drawn a bit smaller than the head->feet gap so the + // legs below it read as two distinct LINES rather than being swallowed + // by the head. + float renderRadius = headRadius * 0.75; + float headDistance = circleDistance(head, renderRadius, target); + + // Legs as thin line-segments (capsules) from the head down to each + // foot, hard-min'd onto the head so the head stays a crisp circle. + float legThickness = footRadius * 0.35; + float leftLeg = segmentDistance(head, leftFoot, target) - legThickness; + float rightLeg = segmentDistance(head, rightFoot, target) - legThickness; + + // Rounded feet at the ends of the lines, kept large enough to stay + // visible even when the leg is short and the head nearly reaches them. + float footRender = footRadius * 0.7; + float leftFootDistance = circleDistance(leftFoot, footRender, target); + float rightFootDistance = circleDistance(rightFoot, footRender, target); + + float body = min( + headDistance, + min(min(leftLeg, rightLeg), min(leftFootDistance, rightFootDistance)) + ); + + // Real eyes painted on the leading face — a bright white sclera with a + // dark pupil — rather than carved holes, so the character reads as + // alive instead of hollow-socketed. + // Big white sclera, small pupil: the eye must read as mostly white so + // it looks like an eye, not a dark socket. (An 8-bit albedo caps the + // sclera at pure white, so its area — not its colour — is what makes + // the eye read brighter against the body.) + vec2 eyeBase = head + forward * (renderRadius * 0.26); + vec2 leftEyeCenter = eyeBase + perp * (renderRadius * 0.38); + vec2 rightEyeCenter = eyeBase - perp * (renderRadius * 0.38); + float scleraRadius = renderRadius * 0.26; + float pupilRadius = renderRadius * 0.11; + + // The pupil slides toward the gaze target (the cursor for the local + // player, otherwise the travel direction), kept well inside the rim so + // a generous ring of white always frames it. The normalize is guarded + // so a cursor resting exactly on an eye can't produce NaNs. + vec2 gazeTarget = characterGazeTargets[i]; + float pupilReach = (scleraRadius - pupilRadius) * 0.6; + vec2 toLeftGaze = gazeTarget - leftEyeCenter; + vec2 toRightGaze = gazeTarget - rightEyeCenter; + vec2 leftGaze = length(toLeftGaze) > 0.001 ? normalize(toLeftGaze) : forward; + vec2 rightGaze = length(toRightGaze) > 0.001 ? normalize(toRightGaze) : forward; + + float sclera = min( + circleDistance(leftEyeCenter, scleraRadius, target), + circleDistance(rightEyeCenter, scleraRadius, target) + ); + float pupil = min( + circleDistance(leftEyeCenter + leftGaze * pupilReach, pupilRadius, target), + circleDistance(rightEyeCenter + rightGaze * pupilReach, pupilRadius, target) + ); + + // Soften each eye edge by at least one screen texel so the colour + // boundary antialiases instead of staircasing when the character is + // small on screen. The fixed world-space term sets a floor on the + // softness when zoomed in; fwidth widens the band to a texel as the + // head shrinks. Computed here in uniform control flow — NOT inside the + // body branch below — so the screen-space derivatives stay defined. + // fwidth needs WebGL2 derivatives (core in GLSL ES 3.00), so the + // WebGL1 fallback keeps the plain world-space band. + float eyeAaBase = renderRadius * 0.025; + #ifdef WEBGL2_IS_AVAILABLE + float scleraAa = max(eyeAaBase, fwidth(sclera)); + float pupilAa = max(eyeAaBase, fwidth(pupil)); + #else + float scleraAa = eyeAaBase; + float pupilAa = eyeAaBase; + #endif + + if (body < minDistance) { + minDistance = body; + // Brief white punch when taking a hit. + color = mix( + readFromPalette(characterColors[i]), + vec4(1.0), + clamp(characterFlash[i], 0.0, 1.0) + ); + + // Paint the eyes over the body colour. The sclera albedo is HDR + // (>> 1): the shading pass multiplies it by the (often dim, reddish) + // scene light before clamping to the screen, so an over-bright white + // reads as a clean white eye everywhere instead of dimming into a + // dark socket. Needs the float colour buffer in sdf-2d; on 8-bit + // fallback it simply clamps back to plain white. The smoothstep + // widths (scleraAa / pupilAa, computed above) antialias the colour + // boundary in a zoom-aware way. + color = mix(color, vec4(10.0, 10.0, 10.0, 1.0), 1.0 - smoothstep(-scleraAa, scleraAa, sclera)); + color = mix(color, vec4(0.04, 0.04, 0.07, 1.0), 1.0 - smoothstep(-pupilAa, pupilAa, pupil)); + } + } + + return minDistance; + } + `, + distanceFunctionName: 'characterMinDistance', + }, + propertyUniformMapping: { + footRadius: 'characterFootRadii', + headRadius: 'characterHeadRadii', + rightFootCenter: 'characterRightFeet', + leftFootCenter: 'characterLeftFeet', + headCenter: 'characterHeadCenters', + gazeTarget: 'characterGazeTargets', + color: 'characterColors', + flash: 'characterFlash', + }, + uniformCountMacroName: 'CHARACTER_COUNT', + shaderCombinationSteps: [0, 1, 2, 8], + empty: new CharacterShape(0), + }; + + protected head!: Circle; + protected leftFoot!: Circle; + protected rightFoot!: Circle; + + // 0..1 transient white flash on taking a hit. Set per frame. + public hitFlash = 0; + // World point the eyes look at; the pupils slide toward it. Set per frame + // (the cursor for the local player, the travel direction for everyone else). + public gazeTarget = vec2.create(); + + public constructor(private readonly color: number) { + super(); + + const circle = new Circle(vec2.create(), 200); + this.setCircles([circle, circle, circle]); + } + + public setCircles([head, leftFoot, rightFoot]: [Circle, Circle, Circle]) { + this.head = head; + this.leftFoot = leftFoot; + this.rightFoot = rightFoot; + } + + public minDistance(target: vec2): number { + return Math.min( + this.head.distance(target), + this.leftFoot.distance(target), + this.rightFoot.distance(target), + ); + } + + protected getObjectToSerialize(transform2d: mat2d, transform1d: number): any { + return { + headCenter: vec2.transformMat2d(vec2.create(), this.head.center, transform2d), + leftFootCenter: vec2.transformMat2d( + vec2.create(), + this.leftFoot.center, + transform2d, + ), + rightFootCenter: vec2.transformMat2d( + vec2.create(), + this.rightFoot.center, + transform2d, + ), + gazeTarget: vec2.transformMat2d(vec2.create(), this.gazeTarget, transform2d), + headRadius: this.head.radius * transform1d, + footRadius: this.leftFoot.radius * transform1d, + color: this.color, + flash: this.hitFlash, + }; + } +} diff --git a/frontend/src/scripts/shapes/planet-shape.ts b/frontend/src/scripts/shapes/planet-shape.ts index 68bccb8..6fa7565 100644 --- a/frontend/src/scripts/shapes/planet-shape.ts +++ b/frontend/src/scripts/shapes/planet-shape.ts @@ -122,8 +122,6 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { }; public randomOffset = 0; - // Radians the surface noise is rotated by; advanced over time by PlanetView so - // the planet's textured surface slowly spins. public rotation = 0; constructor(