Improve gameplay

This commit is contained in:
Andras Schmelczer 2026-06-11 20:26:44 +01:00
parent 7412bc8af5
commit 793d9a81e8
27 changed files with 275 additions and 226 deletions

View file

@ -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<Physical> = [];
const lights: Array<Physical> = [];
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));
};

View file

@ -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,
};

View file

@ -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,

View file

@ -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);
}

View file

@ -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(

View file

@ -5,5 +5,4 @@ export interface Options {
scoreLimit: number;
npcCount: number;
seed: number;
worldSize: number;
}

View file

@ -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,

View file

@ -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();
}
}

View file

@ -1,7 +0,0 @@
import { Command, GameObject } from 'shared';
export class ReactToCollisionCommand extends Command {
public constructor(public readonly other: GameObject) {
super();
}
}