import { vec2 } from 'gl-matrix'; import { clamp, clamp01, id, mix, Random, serializesTo, settings, PlanetBase, CharacterTeam, PropertyUpdatesForObject, UpdatePropertyCommand, CommandExecutors, CommandReceiver, } from 'shared'; import { GeneratePointsCommand } from '../commands/generate-points'; 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; // Planets slowly spin. The angle is authoritative here and streamed to the // client (see getPropertyUpdates), so the rendered outline and this collision // polygon turn as one rigid body. cos/sin are memoised per angle because // distance() is called many times per tick by the raymarcher and SDF sampling. private rotation = 0; private readonly rotationSpeed: number; private cachedRotation = Number.NaN; private cosRotation = 1; private sinRotation = 0; 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); this.rotationSpeed = (0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1); } public distance(target: vec2): number { // Evaluate the SDF in the planet's own rotating frame so this collision // outline turns in lockstep with the rendered planet (see planet-shape.ts). const local = this.toLocalFrame(target); const startEnd = this.vertices[0]; let vb = startEnd; let d = vec2.dist(local, vb); let sign = 1; for (let i = 1; i <= this.vertices.length; i++) { const va = vb; vb = i === this.vertices.length ? startEnd : this.vertices[i]; const targetFromDelta = vec2.subtract(vec2.create(), local, va); const toFromDelta = vec2.subtract(vec2.create(), vb, va); const h = clamp01( vec2.dot(targetFromDelta, toFromDelta) / vec2.squaredLength(toFromDelta), ); const ds = vec2.fromValues( vec2.dist(targetFromDelta, vec2.scale(vec2.create(), toFromDelta, h)), toFromDelta.x * targetFromDelta.y - toFromDelta.y * targetFromDelta.x, ); if ( (local.y >= va.y && local.y < vb.y && ds.y > 0) || (local.y < va.y && local.y >= vb.y && ds.y <= 0) ) { sign *= -1; } d = Math.min(d, ds.x); } return sign * d; } // Rotate a world point by -rotation about the centre, matching the shader's // `localTarget = center + R(rotation) * (target - center)` transform exactly. private toLocalFrame(target: vec2): vec2 { if (this.rotation !== this.cachedRotation) { this.cachedRotation = this.rotation; this.cosRotation = Math.cos(this.rotation); this.sinRotation = Math.sin(this.rotation); } const dx = target.x - this.center.x; const dy = target.y - this.center.y; return vec2.fromValues( this.center.x + this.cosRotation * dx - this.sinRotation * dy, this.center.y + this.sinRotation * dx + this.cosRotation * dy, ); } // Signed angular velocity in rad/s, exposed so a character standing on the // planet can ride its spin (see CharacterPhysical.carryWithRotatingPlanet). public get angularVelocity(): number { return this.rotationSpeed; } public get team(): CharacterTeam { return Math.abs(this.ownership - 0.5) < 0.1 ? CharacterTeam.neutral : this.ownership < 0.5 ? CharacterTeam.blue : CharacterTeam.red; } private timeSinceLastPointGeneration = 0; private getPoints(game: CommandReceiver) { if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) { this.timeSinceLastPointGeneration = 0; const value = Math.round( settings.planetPointGenerationValue * this.sizePointMultiplier, ); game.handleCommand( new GeneratePointsCommand( this.team === CharacterTeam.blue ? value : 0, this.team === CharacterTeam.red ? value : 0, ), ); } } private step({ deltaTimeInSeconds, game }: StepCommand) { this.rotation += deltaTimeInSeconds * this.rotationSpeed; 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.blue ? 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 { return new PropertyUpdatesForObject(this.id, [ new UpdatePropertyCommand('ownership', this.ownership, 0), // Stream rotation with rotationSpeed as the rate-of-change so the client // can keep the angle moving when snapshots run late (see planet-view.ts). new UpdatePropertyCommand('rotation', this.rotation, this.rotationSpeed), ]); } public takeControl(team: CharacterTeam, deltaTime: number) { if (team === CharacterTeam.blue) { this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime; } else if (team === CharacterTeam.red) { this.ownership += (0.5 / settings.takeControlTimeInSeconds) * deltaTime; } else { const previous = this.ownership; this.ownership += -Math.sign(this.ownership - 0.5) * (0.5 / settings.loseControlTimeInSeconds) * deltaTime; if ( (previous < 0.5 && this.ownership > 0.5) || (previous > 0.5 && this.ownership < 0.5) ) { this.ownership = 0.5; } } this.ownership = clamp01(this.ownership); } public get boundingBox(): ImmutableBoundingBox { if (!this._boundingBox) { // The polygon spins about its centre (see distance), so this static box // has to cover every orientation, not just the spawn-time one: take the // circumscribed circle around the rotation centre. const maxVertexDistance = this.vertices.reduce( (max, vertex) => Math.max(max, vec2.distance(this.center, vertex)), 0, ); this._boundingBox = new ImmutableBoundingBox( this.center.x - maxVertexDistance, this.center.x + maxVertexDistance, this.center.y - maxVertexDistance, this.center.y + maxVertexDistance, ); } return this._boundingBox; } 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); vec2.normalize(diff, diff); const scale = clamp( settings.maxGravityQ * ((settings.maxGravityDistance / dist) ** 1.5 - 1), 0, settings.maxGravityStrength, ); return vec2.scale(diff, diff, scale); } public get gameObject(): this { return this; } }