From 793d9a81e8ebc11965d24a785a48ea6ed87dde26 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Thu, 11 Jun 2026 20:26:44 +0100 Subject: [PATCH] Improve gameplay --- Dockerfile | 2 +- backend/src/create-world.ts | 29 +++- backend/src/default-options.ts | 5 +- backend/src/game-server.ts | 2 +- backend/src/objects/lamp-physical.ts | 4 + backend/src/objects/projectile-physical.ts | 7 + backend/src/options.ts | 1 - backend/src/players/player-base.ts | 45 ++++++- backend/src/players/player.ts | 15 +-- backend/src/react-to-collision.ts | 7 - frontend/package-lock.json | 47 +++++-- frontend/package.json | 2 +- frontend/src/scripts/charge-indicator.ts | 58 ++++++++ frontend/src/scripts/game.ts | 47 ++++--- frontend/src/scripts/helper/pointer.ts | 13 ++ frontend/src/scripts/join-form-handler.ts | 15 ++- .../src/scripts/objects/types/lamp-view.ts | 22 ++- .../scripts/objects/types/projectile-view.ts | 5 +- frontend/src/scripts/shapes/blob-shape.ts | 125 ------------------ frontend/webpack.config.js | 6 - .../commands/types/actions/primary-action.ts | 7 +- .../types/actions/secondary-action.ts | 14 -- shared/src/helper/charge.ts | 5 + shared/src/main.ts | 2 +- shared/src/objects/types/character-base.ts | 8 +- shared/src/objects/types/lamp-base.ts | 5 + shared/src/objects/types/planet-base.ts | 3 + 27 files changed, 275 insertions(+), 226 deletions(-) delete mode 100644 backend/src/react-to-collision.ts create mode 100644 frontend/src/scripts/charge-indicator.ts create mode 100644 frontend/src/scripts/helper/pointer.ts delete mode 100644 frontend/src/scripts/shapes/blob-shape.ts delete mode 100644 shared/src/commands/types/actions/secondary-action.ts create mode 100644 shared/src/helper/charge.ts diff --git a/Dockerfile b/Dockerfile index 2c1af5c..83f7922 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,5 +46,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD node -e "require('http').get('http://localhost:3000/state',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" ENTRYPOINT ["node", "dist/main.js"] -# Override these to tune the server, e.g. `--name`, `--playerLimit`, `--worldSize`. +# Override these to tune the server, e.g. `--name`, `--playerLimit`, `--scoreLimit`. CMD ["--port", "3000"] diff --git a/backend/src/create-world.ts b/backend/src/create-world.ts index 1b846ba..b3941a0 100644 --- a/backend/src/create-world.ts +++ b/backend/src/create-world.ts @@ -6,11 +6,11 @@ import { PhysicalContainer } from './physics/containers/physical-container'; import { evaluateSdf } from './physics/functions/evaluate-sdf'; import { Physical } from './physics/physicals/physical'; -export const createWorld = (objectContainer: PhysicalContainer, worldRadius: number) => { +export const createWorld = (objectContainer: PhysicalContainer) => { const objects: Array = []; const lights: Array = []; - for (let r = 0; r < worldRadius; r += settings.radiusSteps) { + for (let r = 0; r < settings.worldRadius; r += settings.radiusSteps) { const circumference = 2 * Math.PI * r; const stepCount = circumference * settings.objectsOnCircleLength; for (let rad = 0; rad < 2 * Math.PI; rad += (2 * Math.PI) / stepCount) { @@ -67,8 +67,29 @@ export const createWorld = (objectContainer: PhysicalContainer, worldRadius: num } } } - console.info('Generated planet count', objects.length); - console.info('Generated light count', lights.length); + console.info(`Generated ${objects.length} planets`); + console.info(`Generated ${lights.length} light`); + + // Associate each lamp with its NEAREST planet, so a planet can repaint "its" + // lamps to the owning team's colour when it flips. Lamps are already placed by + // proximity during world-gen, so the nearest planet is the one whose capture + // they should advertise. Distances use the planet SDF (negative inside), which + // is exactly the "closest planet" metric we want. + const planets = objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical); + lights + .filter((l): l is LampPhysical => l instanceof LampPhysical) + .forEach((lamp) => { + let nearest: PlanetPhysical | undefined; + let nearestDistance = Infinity; + planets.forEach((planet) => { + const distance = planet.distance(lamp.center); + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = planet; + } + }); + nearest?.addLamp(lamp); + }); [...objects, ...lights].forEach((o) => objectContainer.addObject(o)); }; diff --git a/backend/src/default-options.ts b/backend/src/default-options.ts index 5a1c729..1580103 100644 --- a/backend/src/default-options.ts +++ b/backend/src/default-options.ts @@ -4,8 +4,7 @@ export const defaultOptions: Options = { port: 3000, name: 'Test server', playerLimit: 16, - npcCount: 16, + npcCount: 8, seed: Math.random(), - scoreLimit: 1000, - worldSize: 8000, + scoreLimit: 2500, }; diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index 4e53039..78fc5d3 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -41,7 +41,7 @@ export class GameServer extends CommandReceiver { private initialize() { const previousPlayers = this.players; this.objects = new PhysicalContainer(); - createWorld(this.objects, this.options.worldSize); + createWorld(this.objects); this.objects.initialize(); this.players = new PlayerContainer( this.objects, diff --git a/backend/src/objects/lamp-physical.ts b/backend/src/objects/lamp-physical.ts index 3746c01..591b17c 100644 --- a/backend/src/objects/lamp-physical.ts +++ b/backend/src/objects/lamp-physical.ts @@ -32,6 +32,10 @@ export class LampPhysical extends LampBase implements StaticPhysical { return this; } + public queueSetLight(color: vec3, lightness: number) { + this.remoteCall('setLight', color, lightness); + } + public distance(target: vec2): number { return vec2.distance(this.center, target); } diff --git a/backend/src/objects/projectile-physical.ts b/backend/src/objects/projectile-physical.ts index 655ba42..51a7866 100644 --- a/backend/src/objects/projectile-physical.ts +++ b/backend/src/objects/projectile-physical.ts @@ -53,6 +53,13 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica return !this.isDestroyed; } + public get direction(): vec2 { + const direction = vec2.clone(this.velocity); + return vec2.length(direction) > 0 + ? vec2.normalize(direction, direction) + : vec2.fromValues(0, -1); + } + private moveOutsideOfObject() { let wasCollision = true; const delta = vec2.scale( diff --git a/backend/src/options.ts b/backend/src/options.ts index 6304f77..ba28c16 100644 --- a/backend/src/options.ts +++ b/backend/src/options.ts @@ -5,5 +5,4 @@ export interface Options { scoreLimit: number; npcCount: number; seed: number; - worldSize: number; } diff --git a/backend/src/players/player-base.ts b/backend/src/players/player-base.ts index 75fd21a..3d6a561 100644 --- a/backend/src/players/player-base.ts +++ b/backend/src/players/player-base.ts @@ -1,9 +1,17 @@ import { vec2 } from 'gl-matrix'; -import { CommandReceiver, Circle, PlayerInformation, CharacterTeam } from 'shared'; +import { + CommandReceiver, + Circle, + PlayerInformation, + CharacterTeam, + Random, + settings, +} from 'shared'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { isCircleIntersecting } from '../physics/functions/is-circle-intersecting'; import { CharacterPhysical } from '../objects/character-physical'; +import { PlanetPhysical } from '../objects/planet-physical'; import { PlayerContainer } from './player-container'; export abstract class PlayerBase extends CommandReceiver { @@ -22,14 +30,14 @@ export abstract class PlayerBase extends CommandReceiver { super(); } - protected createCharacter(preferredCenter: vec2) { + protected createCharacter() { this.character = new CharacterPhysical( this.playerInfo.name.slice(0, 20), this.sumKills, this.sumDeaths, this.team, this.objectContainer, - this.findEmptyPositionForPlayer(preferredCenter), + this.findEmptyPositionForPlayer(this.findSpawnCenter()), ); this.objectContainer.addObject(this.character); @@ -37,14 +45,37 @@ export abstract class PlayerBase extends CommandReceiver { public abstract step(deltaTimeInSeconds: number): void; - protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 { - if (!preferredCenter) { - preferredCenter = vec2.create(); + private findSpawnCenter(): vec2 { + const planets = this.objectContainer + .findIntersecting( + getBoundingBoxOfCircle(new Circle(vec2.create(), settings.worldRadius * 2)), + ) + .filter((o): o is PlanetPhysical => o instanceof PlanetPhysical); + + const friendly = planets.filter((p) => p.team === this.team); + const neutral = planets.filter((p) => p.team === CharacterTeam.neutral); + const candidates = friendly.length ? friendly : neutral.length ? neutral : planets; + if (candidates.length === 0) { + return vec2.create(); } + const isContested = (planet: PlanetPhysical) => + this.playerContainer.players.some( + (p) => + p.team !== this.team && + p.character?.isAlive && + vec2.distance(p.center, planet.center) < settings.spawnSafetyDistance, + ); + const safe = candidates.filter((p) => !isContested(p)); + + // candidates is non-empty here, so choose() always returns a planet. + return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center); + } + + protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 { let rotation = 0; let radius = 0; - for (;;) { + for (; ;) { const playerPosition = vec2.fromValues( radius * Math.cos(rotation) + preferredCenter.x, radius * Math.sin(rotation) + preferredCenter.y, diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index 75e3e56..1478029 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -42,9 +42,8 @@ export class Player extends PlayerBase { (this.aspectRatio = v.aspectRatio), [MoveActionCommand.type]: (c: MoveActionCommand) => this.character?.handleMovementAction(c), - [PrimaryActionCommand.type]: (c: PrimaryActionCommand) => { - this.character?.shootTowards(c.position); - }, + [PrimaryActionCommand.type]: (c: PrimaryActionCommand) => + this.character?.shootTowards(c.position, c.charge), }; constructor( @@ -60,11 +59,7 @@ export class Player extends PlayerBase { } protected createCharacter() { - const preferredCenter = this.playerContainer.players.find( - (p) => p.character?.isAlive && p.team === this.team, - )?.center; - - super.createCharacter(preferredCenter ?? vec2.create()); + super.createCharacter(); this.objectsPreviouslyInViewArea.push(this.character!); this.queueCommandSend(new CreatePlayerCommand(this.character!)); @@ -206,8 +201,4 @@ export class Player extends PlayerBase { this.queueCommandSend(new ServerAnnouncement(announcement)); } } - - public destroy() { - super.destroy(); - } } diff --git a/backend/src/react-to-collision.ts b/backend/src/react-to-collision.ts deleted file mode 100644 index fdcd492..0000000 --- a/backend/src/react-to-collision.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Command, GameObject } from 'shared'; - -export class ReactToCollisionCommand extends Command { - public constructor(public readonly other: GameObject) { - super(); - } -} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 972263f..9652017 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,7 +23,7 @@ "resize-observer-polyfill": "^1.5.1", "sass": "^1.100.0", "sass-loader": "^17.0.0", - "sdf-2d": "^0.7.6", + "sdf-2d": "file:../../sdf-2d", "shared": "file:../shared", "socket.io-client": "^4.8.3", "socket.io-msgpack-parser": "^3.0.2", @@ -36,6 +36,40 @@ "webpack-dev-server": "^5.2.4" } }, + "../../sdf-2d": { + "version": "0.7.6", + "dev": true, + "license": "ISC", + "dependencies": { + "gl-matrix": "^3.4.4", + "resize-observer-polyfill": "^1.5.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@typescript-eslint/eslint-plugin": "^8.60.1", + "@typescript-eslint/parser": "^8.60.1", + "eslint": "^10.4.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "eslint-plugin-unused-imports": "^4.4.1", + "globals": "^17.6.0", + "prettier": "^3.8.3", + "raw-loader": "^4.0.2", + "terser-webpack-plugin": "^5.6.1", + "ts-loader": "^9.6.0", + "typedoc": "^0.28.19", + "typedoc-plugin-extras": "^4.0.1", + "typescript": "^6.0.3", + "webpack": "^5.107.2", + "webpack-cli": "^7.0.3" + } + }, + "../../sdf-2d/dist": { + "extraneous": true + }, + "../../sdf-2d/lib": { + "extraneous": true + }, "../shared": { "version": "0.0.0", "dev": true, @@ -5409,15 +5443,8 @@ } }, "node_modules/sdf-2d": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/sdf-2d/-/sdf-2d-0.7.6.tgz", - "integrity": "sha512-aBJUjwYjWP+/fSvLHH6vhpFyWeSJNCSEHpE0XBX69s2bQlo/NOUr3nq/KuhRZ6A3FeXrGU4lzyurHJ1N8f4rFg==", - "dev": true, - "license": "ISC", - "dependencies": { - "gl-matrix": "^3.3.0", - "resize-observer-polyfill": "^1.5.1" - } + "resolved": "../../sdf-2d", + "link": true }, "node_modules/select-hose": { "version": "2.0.0", diff --git a/frontend/package.json b/frontend/package.json index 9d94057..6dc2f68 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,7 +37,7 @@ "resize-observer-polyfill": "^1.5.1", "sass": "^1.100.0", "sass-loader": "^17.0.0", - "sdf-2d": "^0.7.6", + "sdf-2d": "^0.8.0", "shared": "file:../shared", "socket.io-client": "^4.8.3", "socket.io-msgpack-parser": "^3.0.2", diff --git a/frontend/src/scripts/charge-indicator.ts b/frontend/src/scripts/charge-indicator.ts new file mode 100644 index 0000000..7b44718 --- /dev/null +++ b/frontend/src/scripts/charge-indicator.ts @@ -0,0 +1,58 @@ +import { holdDurationToCharge } from 'shared'; +import { Pointer } from './helper/pointer'; + + +export abstract class ChargeIndicator { + private static element?: HTMLElement; + private static heldSince = 0; + private static raf = 0; + private static followPointer = false; + + public static begin(x: number, y: number, followPointer = false) { + this.end(); + + const element = document.createElement('div'); + element.className = 'charge-ring'; + element.style.left = `${x}px`; + element.style.top = `${y}px`; + document.body.appendChild(element); + + this.element = element; + this.heldSince = performance.now(); + this.followPointer = followPointer; + this.raf = requestAnimationFrame(this.update); + } + + public static end() { + if (this.element) { + cancelAnimationFrame(this.raf); + this.element.parentElement?.removeChild(this.element); + this.element = undefined; + } + } + + private static update = () => { + const element = ChargeIndicator.element; + if (!element) { + return; + } + + const charge = holdDurationToCharge( + (performance.now() - ChargeIndicator.heldSince) / 1000, + ); + element.style.opacity = charge < 0.12 ? '0' : '1'; + element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${charge * 360 + }deg, rgba(255, 255, 255, 0.15) 0deg)`; + element.classList.toggle('full', charge >= 1); + + if (ChargeIndicator.followPointer) { + const cursor = Pointer.getDisplayPosition(); + if (cursor) { + element.style.left = `${cursor.x}px`; + element.style.top = `${cursor.y}px`; + } + } + + ChargeIndicator.raf = requestAnimationFrame(ChargeIndicator.update); + }; +} diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index 971ca98..bce7784 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -30,10 +30,12 @@ import { CommandSocket } from './commands/command-socket'; import { PlayerDecision } from './join-form-handler'; import { GameObjectContainer } from './objects/game-object-container'; import parser from 'socket.io-msgpack-parser'; -import { BlobShape } from './shapes/blob-shape'; +import { CharacterShape } from './shapes/character-shape'; import { PlanetShape } from './shapes/planet-shape'; import { RenderCommand } from './commands/types/render'; import { StepCommand } from './commands/types/step'; +import { Tutorial } from './tutorial'; +import { Scoreboard } from './scoreboard'; export class Game extends CommandReceiver { public gameObjects = new GameObjectContainer(this); @@ -48,12 +50,11 @@ export class Game extends CommandReceiver { private mouseListener: MouseListener; private touchListener: TouchListener; - private declaPlanetCountElement = document.createElement('div'); - private redPlanetCountElement = document.createElement('div'); + private scoreboard = new Scoreboard(); private announcementText = document.createElement('h2'); - private progressBar = document.createElement('div'); private arrows: { [id: number]: HTMLElement } = {}; private socketReceiver!: CommandSocket; + private tutorial!: Tutorial; constructor( private readonly playerDecision: PlayerDecision, @@ -63,9 +64,6 @@ export class Game extends CommandReceiver { super(); this.started = new Promise((r) => (this.resolveStarted = r)); this.announcementText.className = 'announcement'; - this.progressBar.className = 'planet-progress'; - this.progressBar.appendChild(this.declaPlanetCountElement); - this.progressBar.appendChild(this.redPlanetCountElement); this.keyboardListener = new KeyboardListener(); this.mouseListener = new MouseListener(this.canvas, this); @@ -78,12 +76,14 @@ export class Game extends CommandReceiver { this.socket?.close(); this.gameObjects = new GameObjectContainer(this); this.overlay.innerHTML = ''; + this.arrows = {}; this.isEnding = false; this.lastAnnouncementText = ''; - this.overlay.appendChild(this.progressBar); + this.overlay.appendChild(this.scoreboard.element); this.announcementText.innerText = ''; this.timeScaling = 1; this.overlay.appendChild(this.announcementText); + this.tutorial = new Tutorial(this.overlay); this.socket = io(this.playerDecision.server, { reconnectionDelayMax: 10000, @@ -114,12 +114,17 @@ export class Game extends CommandReceiver { }); this.socketReceiver = new CommandSocket(this.socket); + // The tutorial listens to the same input streams as the socket, so its + // stages clear off the player's own commands without any server involvement. this.keyboardListener.clearSubscribers(); this.keyboardListener.subscribe(this.socketReceiver); + this.keyboardListener.subscribe(this.tutorial); this.mouseListener.clearSubscribers(); this.mouseListener.subscribe(this.socketReceiver); + this.mouseListener.subscribe(this.tutorial); this.touchListener.clearSubscribers(); this.touchListener.subscribe(this.socketReceiver); + this.touchListener.subscribe(this.tutorial); this.isBetweenGames = false; @@ -189,9 +194,8 @@ export class Game extends CommandReceiver { clamp(p.x, arrowPadding, width - arrowPadding), clamp(height - p.y, arrowPadding, height - arrowPadding), ); - e.style.transform = `translateX(${p.x}px) translateY(${ - p.y - }px) translateX(-50%) translateY(-50%) rotate(${-angle + Math.PI / 2}rad) `; + e.style.transform = `translateX(${p.x}px) translateY(${p.y + }px) translateX(-50%) translateY(-50%) rotate(${-angle + Math.PI / 2}rad) `; }); for (const id in this.arrows) { @@ -214,7 +218,7 @@ export class Game extends CommandReceiver { this.canvas, [ PlanetShape.descriptor, - BlobShape.descriptor, + CharacterShape.descriptor, { ...CircleLight.descriptor, shaderCombinationSteps: [0, 1, 2, 4, 8, 16], @@ -223,10 +227,11 @@ export class Game extends CommandReceiver { this.gameLoop.bind(this), { shadowTraceCount: 16, - paletteSize: settings.palette.length, - colorPalette: settings.palette, + paletteSize: settings.paletteDim.length, + colorPalette: settings.paletteDim, enableHighDpiRendering: true, lightCutoffDistance: settings.lightCutoffDistance, + lightOverlapReduction: settings.lightOverlapReduction, textures: { noiseTexture: { source: noiseTexture, @@ -276,7 +281,9 @@ export class Game extends CommandReceiver { this.draw(); } - if ((this.timeSinceLastAnnouncement += deltaTime) > 0.5) { + if ( + (this.timeSinceLastAnnouncement += deltaTime) > settings.announcementVisibleSeconds + ) { this.lastAnnouncementText = ''; } @@ -292,6 +299,10 @@ export class Game extends CommandReceiver { new RenderCommand(this.renderer, this.overlay, shouldChangeLayout), ); + this.touchListener.update(deltaTime); + + this.tutorial.step(this.gameObjects); + this.socketReceiver.sendQueuedCommands(); return this.isActive; @@ -299,10 +310,8 @@ export class Game extends CommandReceiver { private draw() { if (this.lastGameState) { - this.declaPlanetCountElement.style.width = - (this.lastGameState.declaCount / this.lastGameState.limit) * 50 + '%'; - this.redPlanetCountElement.style.width = - (this.lastGameState.redCount / this.lastGameState.limit) * 50 + '%'; + // The local player's team is read off the main character once it exists. + this.scoreboard.update(this.lastGameState, this.gameObjects.player?.team); } if (this.lastOtherPlayerDirections) { diff --git a/frontend/src/scripts/helper/pointer.ts b/frontend/src/scripts/helper/pointer.ts new file mode 100644 index 0000000..ab8886d --- /dev/null +++ b/frontend/src/scripts/helper/pointer.ts @@ -0,0 +1,13 @@ +import { vec2 } from 'gl-matrix'; + +export class Pointer { + private static displayPosition: vec2 | null = null; + + public static setDisplayPosition(x: number, y: number): void { + Pointer.displayPosition = vec2.fromValues(x, y); + } + + public static getDisplayPosition(): vec2 | null { + return Pointer.displayPosition; + } +} diff --git a/frontend/src/scripts/join-form-handler.ts b/frontend/src/scripts/join-form-handler.ts index 2d86296..e9c2b71 100644 --- a/frontend/src/scripts/join-form-handler.ts +++ b/frontend/src/scripts/join-form-handler.ts @@ -102,7 +102,7 @@ export class JoinFormHandler { private removeServer(server: ServerChooserOption) { this.servers = this.servers.filter((s) => s !== server); - if (this.servers.length) { + if (!this.servers.length) { this.joinButton.disabled = true; } } @@ -143,16 +143,17 @@ class ServerChooserOption { this.setServerInfoLabelText(); this.socket = io(url, { - reconnection: false, + reconnection: true, + reconnectionAttempts: 5, timeout: 4000, parser, } as any); - // `connect_timeout` was removed in socket.io-client v3+; connection - // timeouts now surface through `connect_error` (reconnection is disabled). - this.socket.on('connect_error', this.destroy.bind(this)); - this.socket.on('disconnect', this.destroy.bind(this)); - this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates); + this.socket.io.on('reconnect_failed', this.destroy.bind(this)); + + this.socket.on('connect', () => + this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates), + ); this.socket.on( TransportEvents.ServerInfoUpdate, ([playerCount, gameState]: [number, number]) => { diff --git a/frontend/src/scripts/objects/types/lamp-view.ts b/frontend/src/scripts/objects/types/lamp-view.ts index 885ab85..e4db4f6 100644 --- a/frontend/src/scripts/objects/types/lamp-view.ts +++ b/frontend/src/scripts/objects/types/lamp-view.ts @@ -1,18 +1,36 @@ import { vec2, vec3 } from 'gl-matrix'; import { CircleLight } from 'sdf-2d'; -import { CommandExecutors, Id, LampBase } from 'shared'; +import { CommandExecutors, Id, LampBase, mixRgb, settings } from 'shared'; import { RenderCommand } from '../../commands/types/render'; +import { StepCommand } from '../../commands/types/step'; export class LampView extends LampBase { private light: CircleLight; + private targetColor: vec3; + private targetLightness: number; + protected commandExecutors: CommandExecutors = { [RenderCommand.type]: this.draw.bind(this), + [StepCommand.type]: this.step.bind(this), }; constructor(id: Id, center: vec2, color: vec3, lightness: number) { super(id, center, color, lightness); - this.light = new CircleLight(center, color, lightness); + this.light = new CircleLight(vec2.clone(center), vec3.clone(color), lightness); + this.targetColor = vec3.clone(color); + this.targetLightness = lightness; + } + + public setLight(color: vec3, lightness: number) { + this.targetColor = vec3.clone(color); + this.targetLightness = lightness; + } + + private step({ deltaTimeInSeconds }: StepCommand): void { + const t = 1 - Math.exp(-deltaTimeInSeconds / settings.lampLerpSeconds); + this.light.color = mixRgb(this.light.color, this.targetColor, t); + this.light.intensity += (this.targetLightness - this.light.intensity) * t; } private draw({ renderer }: RenderCommand): void { diff --git a/frontend/src/scripts/objects/types/projectile-view.ts b/frontend/src/scripts/objects/types/projectile-view.ts index a433ccd..7a246fe 100644 --- a/frontend/src/scripts/objects/types/projectile-view.ts +++ b/frontend/src/scripts/objects/types/projectile-view.ts @@ -48,7 +48,10 @@ export class ProjectileView extends ProjectileBase { this.center = this.centerExtrapolator.getValue(deltaTimeInSeconds); this.light.center = this.center; - this.light.intensity = (0.15 * this.strength) / settings.projectileMaxStrength; + this.light.intensity = Math.min( + 0.1, + (0.15 * this.strength) / settings.projectileMaxStrength, + ); } private draw({ renderer }: RenderCommand): void { diff --git a/frontend/src/scripts/shapes/blob-shape.ts b/frontend/src/scripts/shapes/blob-shape.ts deleted file mode 100644 index 7e0d500..0000000 --- a/frontend/src/scripts/shapes/blob-shape.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { mat2d, vec2 } from 'gl-matrix'; -import { Drawable, DrawableDescriptor } from 'sdf-2d'; -import { Circle } from 'shared'; - -export class BlobShape extends Drawable { - public static descriptor: DrawableDescriptor = { - sdf: { - shader: ` - uniform vec2 headCenters[BLOB_COUNT]; - uniform vec2 leftFootCenters[BLOB_COUNT]; - uniform vec2 rightFootCenters[BLOB_COUNT]; - uniform float headRadii[BLOB_COUNT]; - uniform float footRadii[BLOB_COUNT]; - uniform int blobColors[BLOB_COUNT]; - - float blobSmoothMin(float a, float b) - { - const highp float k = 300.0; - highp float res = exp2(-k * a) + exp2(-k * b); - return -log2(res) / k; - } - - float circleDistance(vec2 circleCenter, float radius, vec2 target) { - return distance(target, circleCenter) - radius; - } - - float blobMinDistance(vec2 target, out vec4 color) { - float minDistance = 1000.0; - - for (int i = 0; i < BLOB_COUNT; i++) { - float headDistance = circleDistance(headCenters[i], headRadii[i], target); - float leftFootDistance = circleDistance(leftFootCenters[i], footRadii[i], target); - float rightFootDistance = circleDistance(rightFootCenters[i], footRadii[i], target); - - float res = min( - blobSmoothMin(headDistance, leftFootDistance), - blobSmoothMin(headDistance, rightFootDistance) - ); - - vec2 leftEyeOffset = vec2(-headRadii[i] * 0.35, headRadii[i] * 0.2); - vec2 rightEyeOffset = vec2(headRadii[i] * 0.35, headRadii[i] * 0.2); - - float eyeDistance = min( - circleDistance(headCenters[i] + rightEyeOffset, headRadii[i] * 0.25, target), - circleDistance(headCenters[i] + leftEyeOffset, headRadii[i] * 0.25, target) - ); - - eyeDistance = max( - eyeDistance, - -circleDistance(headCenters[i] + leftEyeOffset + vec2(0, -headRadii[i] * 0.175), headRadii[i] * 0.2, target) - ); - - eyeDistance = max( - eyeDistance, - -circleDistance(headCenters[i] + rightEyeOffset + vec2(0, -headRadii[i] * 0.175), headRadii[i] * 0.2, target) - ); - - if (res < minDistance) { - minDistance = res; - color = eyeDistance < 0.0 ? vec4(1.0) : readFromPalette(blobColors[i]); - } - } - - return minDistance; - } - `, - distanceFunctionName: 'blobMinDistance', - }, - propertyUniformMapping: { - footRadius: 'footRadii', - headRadius: 'headRadii', - rightFootCenter: 'rightFootCenters', - leftFootCenter: 'leftFootCenters', - headCenter: 'headCenters', - color: 'blobColors', - }, - uniformCountMacroName: 'BLOB_COUNT', - shaderCombinationSteps: [0, 1, 2, 8], - empty: new BlobShape(0), - }; - - protected head!: Circle; - protected leftFoot!: Circle; - protected rightFoot!: Circle; - - public constructor(private readonly color: number) { - super(); - - const circle = new Circle(vec2.create(), 200); - this.setCircles([circle, circle, circle]); - } - - public setCircles([head, leftFoot, rightFoot]: [Circle, Circle, Circle]) { - this.head = head; - this.leftFoot = leftFoot; - this.rightFoot = rightFoot; - } - - public minDistance(target: vec2): number { - return Math.min( - this.head.distance(target), - this.leftFoot.distance(target), - this.rightFoot.distance(target), - ); - } - - protected getObjectToSerialize(transform2d: mat2d, transform1d: number): any { - return { - headCenter: vec2.transformMat2d(vec2.create(), this.head.center, transform2d), - leftFootCenter: vec2.transformMat2d( - vec2.create(), - this.leftFoot.center, - transform2d, - ), - rightFootCenter: vec2.transformMat2d( - vec2.create(), - this.rightFoot.center, - transform2d, - ), - headRadius: this.head.radius * transform1d, - footRadius: this.leftFoot.radius * transform1d, - color: this.color, - }; - } -} diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 0264401..97f6f6b 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -137,12 +137,6 @@ module.exports = (env, argv) => { }, resolve: { extensions: ['.ts', '.js', '.json'], - alias: { - // sdf-2d's package.json `exports` only declares an `import` condition, - // which webpack 5 cannot resolve for the production (require) build. - // Point straight at its entry to bypass package exports resolution. - 'sdf-2d$': path.resolve(__dirname, 'node_modules/sdf-2d/lib/main.js'), - }, }, }; }; diff --git a/shared/src/commands/types/actions/primary-action.ts b/shared/src/commands/types/actions/primary-action.ts index 268828b..54b92c9 100644 --- a/shared/src/commands/types/actions/primary-action.ts +++ b/shared/src/commands/types/actions/primary-action.ts @@ -4,11 +4,14 @@ import { Command } from '../../command'; @serializable export class PrimaryActionCommand extends Command { - public constructor(public readonly position: vec2) { + public constructor( + public readonly position: vec2, + public readonly charge: number = 0, + ) { super(); } public toArray(): Array { - return [this.position]; + return [this.position, this.charge]; } } diff --git a/shared/src/commands/types/actions/secondary-action.ts b/shared/src/commands/types/actions/secondary-action.ts deleted file mode 100644 index 3ca0293..0000000 --- a/shared/src/commands/types/actions/secondary-action.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { serializable } from '../../../serialization/serializable'; -import { Command } from '../../command'; - -@serializable -export class SecondaryActionCommand extends Command { - public constructor(public readonly position: vec2) { - super(); - } - - public toArray(): Array { - return [this.position]; - } -} diff --git a/shared/src/helper/charge.ts b/shared/src/helper/charge.ts new file mode 100644 index 0000000..1ab6ece --- /dev/null +++ b/shared/src/helper/charge.ts @@ -0,0 +1,5 @@ +import { clamp01 } from './clamp'; +import { settings } from '../settings'; + +export const holdDurationToCharge = (heldSeconds: number): number => + clamp01(heldSeconds / settings.chargeShotFullHoldSeconds); diff --git a/shared/src/main.ts b/shared/src/main.ts index bc8cdec..949cc2d 100644 --- a/shared/src/main.ts +++ b/shared/src/main.ts @@ -16,7 +16,6 @@ export * from './commands/command-generator'; export * from './commands/types/actions/move-action'; export * from './commands/types/actions/primary-action'; export * from './commands/types/actions/set-aspect-ratio-action'; -export * from './commands/types/actions/secondary-action'; export * from './helper/array'; export * from './helper/last'; export * from './helper/rgb'; @@ -24,6 +23,7 @@ export * from './helper/hsl'; export * from './helper/rgb255'; export * from './helper/mix-rgb'; export * from './helper/clamp'; +export * from './helper/charge'; export * from './helper/calculate-view-area'; export * from './helper/circle'; export * from './helper/rectangle'; diff --git a/shared/src/objects/types/character-base.ts b/shared/src/objects/types/character-base.ts index 11540bd..69cc670 100644 --- a/shared/src/objects/types/character-base.ts +++ b/shared/src/objects/types/character-base.ts @@ -26,13 +26,17 @@ export class CharacterBase extends GameObject { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onShoot(strength: number) {} + public onShoot(strength: number) { } + + public onHitConfirmed() { } + + public onKillConfirmed(victimName?: string, streak?: number) { } public setHealth(health: number) { this.health = health; } - public onDie() {} + public onDie() { } public setKillCount(killCount: number) { this.killCount = killCount; diff --git a/shared/src/objects/types/lamp-base.ts b/shared/src/objects/types/lamp-base.ts index 6990314..794e75b 100644 --- a/shared/src/objects/types/lamp-base.ts +++ b/shared/src/objects/types/lamp-base.ts @@ -13,6 +13,11 @@ export class LampBase extends GameObject { super(id); } + // Overridden by LampView (which lerps toward the new colour/brightness); a + // no-op on the wire base. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public setLight(color: vec3, lightness: number) {} + public toArray(): Array { const { id, center, color, lightness } = this; return [id, center, color, lightness]; diff --git a/shared/src/objects/types/planet-base.ts b/shared/src/objects/types/planet-base.ts index 1ac50e3..ba90ff2 100644 --- a/shared/src/objects/types/planet-base.ts +++ b/shared/src/objects/types/planet-base.ts @@ -4,6 +4,7 @@ import { settings } from '../../settings'; import { serializable } from '../../serialization/serializable'; import { GameObject } from '../game-object'; import { Id } from '../../communication/id'; +import { CharacterTeam } from './character-base'; @serializable export class PlanetBase extends GameObject { @@ -25,6 +26,8 @@ export class PlanetBase extends GameObject { // eslint-disable-next-line @typescript-eslint/no-unused-vars public generatedPoints(value: number) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public onFlipped(team: CharacterTeam) {} public static createPlanetVertices( center: vec2,