diff --git a/src/ecs/Components.ts b/src/ecs/Components.ts index 3860231..90d79fa 100644 --- a/src/ecs/Components.ts +++ b/src/ecs/Components.ts @@ -2,11 +2,23 @@ import { Layer, SpriteSheet } from "../applet/Render"; import { Component, copyDense, copySparse, EntityState, StateForSchema, Store } from "./Data"; +/** + * Nominal type used to remind users to enforce some level + * of quantization on numbers kept in game state. This reduces + * physics precision in theory, but lets us feel more comfortable + * about determinism for rollback physics. + */ +export type FixedPoint = number & {_type: "fixed point"}; + +export function Floor(x: number): FixedPoint { + return Math.floor(x) as FixedPoint; +} + export class Box { constructor( - public x: number, public y: number, - public w: number, public h: number - ) {}; + public x: FixedPoint, public y: FixedPoint, + public w: FixedPoint, public h: FixedPoint + ) { }; }; /** @@ -14,7 +26,7 @@ export class Box { */ export function Approach(source: number, target: number, speed: number): number { const delta = target - source; - if(Math.abs(delta) <= speed) { + if (Math.abs(delta) <= speed) { return target; } else { return source + Math.sign(delta) * speed; @@ -25,10 +37,10 @@ export function Approach(source: number, target: number, speed: number): number * pairs of vertex coordinates in ccw winding order */ export interface Polygon { - points: number[]; + points: FixedPoint[]; } export class PolygonComponent extends Component { - points: number[]; + points: FixedPoint[]; constructor(from: Partial) { super(from); this.points = from.points?.slice() ?? [] @@ -39,16 +51,16 @@ export class PolygonComponent extends Component { } export class Location extends Component { - X: number; - Y: number; + X: FixedPoint; + Y: FixedPoint; Angle: number; VX: number; VY: number; VAngle: number; constructor(from: Partial) { super(from); - this.X = from.X ?? 0; - this.Y = from.Y ?? 0; + this.X = from.X ?? (0 as FixedPoint); + this.Y = from.Y ?? (0 as FixedPoint); this.Angle = from.Angle ?? 0; this.VX = from.VX ?? 0; this.VY = from.VY ?? 0; @@ -90,7 +102,7 @@ export class RenderSprite extends Component { public index: number; public offsetX: number; public offsetY: number; - constructor(from: Partial & {sheet: SpriteSheet}) { + constructor(from: Partial & { sheet: SpriteSheet }) { super(from); this.sheet = from.sheet; this.layer = from.layer ?? 1; diff --git a/src/ecs/Location.ts b/src/ecs/Location.ts index 9189867..7d87b36 100644 --- a/src/ecs/Location.ts +++ b/src/ecs/Location.ts @@ -1,4 +1,4 @@ -import { Data, Location, Polygon } from "./Components"; +import { Data, Floor, Location, Polygon } from "./Components"; import { Join } from "./Data"; export function TransformCx(cx: CanvasRenderingContext2D, location: Location, dt = 0) { @@ -9,20 +9,20 @@ export function TransformCx(cx: CanvasRenderingContext2D, location: Location, dt export function TfPolygon({points}: Polygon, {X, Y, Angle}: Location): Polygon { const sin = Math.sin(Angle); const cos = Math.cos(Angle); - const result = {points: new Array(points.length)}; + const result: Polygon = {points: new Array(points.length)}; for(let i = 0; i < points.length; i += 2) { const x = points[i]; const y = points[i+1]; - result.points[i] = x*cos - y*sin + X; - result.points[i+1] = x*sin + y*cos + Y; + result.points[i] = Floor(x*cos - y*sin + X); + result.points[i+1] = Floor(x*sin + y*cos + Y); } return result; } export function DumbMotion(data: Data, interval: number) { Join(data, "location").forEach(([location]) => { - location.X += location.VX * interval; - location.Y += location.VY * interval; + location.X = Floor(location.X + location.VX * interval); + location.Y = Floor(location.Y + location.VY * interval); location.Angle += location.VAngle * interval; }); } diff --git a/src/ecs/test.ts b/src/ecs/test.ts index 52f94c0..d41bb6f 100644 --- a/src/ecs/test.ts +++ b/src/ecs/test.ts @@ -7,6 +7,7 @@ import { CollisionClass, ComponentSchema, Data, + FixedPoint, Location, PolygonComponent, RenderBounds, @@ -164,11 +165,11 @@ class LoopTest { // spinner box Create(this.data, { location: new Location({ - X: 200, - Y: 200, + X: 200 as FixedPoint, + Y: 200 as FixedPoint, VAngle: Math.PI }), - bounds: new PolygonComponent({points: [-50, 50, -60, 250, 60, 250, 50, 50]}), + bounds: new PolygonComponent({points: [-50, 50, -60, 250, 60, 250, 50, 50] as FixedPoint[]}), collisionTargetClass: new CollisionClass({ name: "block"}), renderBounds: new RenderBounds({color: "#0a0", layer: 0}), }); @@ -176,12 +177,12 @@ class LoopTest { // triangles [0, 1, 2, 3, 4, 5].forEach(angle => Create(this.data, { location: new Location({ - X: 200, - Y: 200, + X: 200 as FixedPoint, + Y: 200 as FixedPoint, Angle: angle, VAngle: -Math.PI/10 }), - bounds: new PolygonComponent({points: [70, 0, 55, 40, 85, 40]}), + bounds: new PolygonComponent({points: [70, 0, 55, 40, 85, 40] as FixedPoint[]}), collisionSourceClass: new CollisionClass({ name: "tri"}), renderBounds: new RenderBounds({ color: "#d40", diff --git a/src/game/Death.ts b/src/game/Death.ts index 25a16d8..318f558 100644 --- a/src/game/Death.ts +++ b/src/game/Death.ts @@ -1,5 +1,12 @@ import { PlaySfx } from "../applet/Audio"; -import { Location, Polygon, PolygonComponent, RenderBounds } from "../ecs/Components"; +import { + FixedPoint, + Floor, + Location, + Polygon, + PolygonComponent, + RenderBounds, +} from "../ecs/Components"; import { Create, Id, Join, Lookup, Remove } from "../ecs/Data"; import { Data, Lifetime, Teams, World } from "./GameComponents"; @@ -39,20 +46,20 @@ export function CheckLifetime(data: Data, world: World, interval: number) { export function SmokeDamage(data: Data, world: World) { Join(data, "hp", "location").forEach(([hp, {X, Y}]) => { // convert dealt damage to particles - const puffs = Math.floor(hp.receivedDamage / 3); - SpawnBlast(data, world, X, Y, 2, "#000", puffs); - hp.receivedDamage = Math.floor(hp.receivedDamage % 3); + const puffs = Floor(hp.receivedDamage / 3); + SpawnBlast(data, world, X, Y, 2 as FixedPoint, "#000", puffs); + hp.receivedDamage = Floor(hp.receivedDamage % 3); }); } -function SpawnBlast(data: Data, world: World, x: number, y: number, size: number, color: string, count: number) { +function SpawnBlast(data: Data, world: World, x: FixedPoint, y: FixedPoint, size: FixedPoint, color: string, count: FixedPoint) { for(let puff = 0; puff < count; puff++) { const angle = Math.PI * 2 * puff / count; SpawnPuff(data, world, x, y, size, color, angle); } } -function SpawnPuff(data: Data, world: World, x: number, y: number, size: number, color: string, angle: number): Id { +function SpawnPuff(data: Data, world: World, x: FixedPoint, y: FixedPoint, size: FixedPoint, color: string, angle: number): Id { return Create(data, { location: new Location({ X: x, @@ -65,7 +72,7 @@ function SpawnPuff(data: Data, world: World, x: number, y: number, size: number, -size, size, size, size, size, -size - ]}), + ] as FixedPoint[]}), renderBounds: new RenderBounds({ color, // TODO: work out standard layers diff --git a/src/game/GameComponents.ts b/src/game/GameComponents.ts index 74ce06a..59c623a 100644 --- a/src/game/GameComponents.ts +++ b/src/game/GameComponents.ts @@ -1,6 +1,6 @@ import { KeyName } from "../applet/Keyboard"; import { DrawSet, Layer } from "../applet/Render"; -import { ComponentSchema, Data as EcsData } from "../ecs/Components"; +import { ComponentSchema, Data as EcsData, FixedPoint } from "../ecs/Components"; import { Component, copySparse, Join, StateForSchema, Store } from "../ecs/Data"; import { DumbMotion } from "../ecs/Location"; import { LockstepProcessor, TICK_LENGTH } from "../ecs/Lockstep"; @@ -14,8 +14,8 @@ export enum GamePhase { } export type RGB = [number, number, number]; export class World { - width = 500; - height = 400; + width = 500 as FixedPoint; + height = 400 as FixedPoint; /* * Core Game Status @@ -147,14 +147,14 @@ export class Boss extends Component { } export class Message extends Component { - targetY = 0; + targetY = 0 as FixedPoint; layer: number; color: string; message: string; timeout = 3; constructor(from: Partial) { super(from); - this.targetY = from.targetY ?? 0; + this.targetY = from.targetY ?? (0 as FixedPoint); this.layer = from.layer ?? 1; this.color = from.color ?? "#000"; this.message = from.message ?? ""; @@ -219,7 +219,7 @@ export class Engine implements LockstepProcessor { if (playerInput.indexOf("right") != -1) { dir += 1; } - location.VAngle = dir * 0.01; + location.VAngle = (dir * 0.01) as FixedPoint; } }); } diff --git a/src/game/Main.ts b/src/game/Main.ts index c94790c..fa62977 100644 --- a/src/game/Main.ts +++ b/src/game/Main.ts @@ -3,7 +3,7 @@ import subscribe from "callbag-subscribe"; import { KeyControl, KeyName } from "../applet/Keyboard"; import { DrawSet } from "../applet/Render"; -import { Location, PolygonComponent, RenderBounds } from "../ecs/Components"; +import { FixedPoint, Location, PolygonComponent, RenderBounds } from "../ecs/Components"; import { Create } from "../ecs/Data"; import { RunRenderBounds } from "../ecs/Renderers"; import { LockstepClient, Server } from "../net/LockstepClient"; @@ -46,10 +46,10 @@ export class Main extends LockstepClient { playerNumber: 0 }), location: new Location({ - X: 100, - Y: 200, + X: 100 as FixedPoint, + Y: 200 as FixedPoint, }), - bounds: new PolygonComponent({points: [-30, 0, 30, 0, 0, 40]}), + bounds: new PolygonComponent({points: [-30, 0, 30, 0, 0, 40] as FixedPoint[]}), renderBounds: new RenderBounds({ color: "#a0f", layer: 0 @@ -61,10 +61,10 @@ export class Main extends LockstepClient { playerNumber: 1 }), location: new Location({ - X: 400, - Y: 200, + X: 400 as FixedPoint, + Y: 200 as FixedPoint, }), - bounds: new PolygonComponent({points: [-30, 0, 30, 0, 0, 40]}), + bounds: new PolygonComponent({points: [-30, 0, 30, 0, 0, 40] as FixedPoint[]}), renderBounds: new RenderBounds({ color: "#f0a", layer: 0 diff --git a/src/game/Message.ts b/src/game/Message.ts index 6f898b0..95801a8 100644 --- a/src/game/Message.ts +++ b/src/game/Message.ts @@ -1,4 +1,5 @@ import { DrawSet } from "../applet/Render"; +import { Floor } from "../ecs/Components"; import { Join, Remove } from "../ecs/Data"; import { TransformCx } from "../ecs/Location"; import { Data, GamePhase, World } from "./GameComponents"; @@ -22,23 +23,23 @@ export function ArrangeMessages(data: Data, world: World, interval: number) { const messages = Join(data, "message", "location"); messages.sort(([{timeout: timeoutA}, {}], [{timeout: timeoutB}, {}]) => timeoutA - timeoutB); - let y = world.height / 3; + let y = Floor(world.height / 3); messages.forEach(([message, location]) => { message.targetY = y; - y += ADVANCE; + y = Floor(y + ADVANCE); const delta = message.targetY - location.Y; if(Math.abs(delta) < 100 * interval) { location.Y = message.targetY; - location.VY = 0; + location.VY = Floor(0); } else { - location.VY = Math.sign(delta) * 100; + location.VY = Floor(Math.sign(delta) * 100); } if(location.X >= world.width / 2 && message.timeout >= 0) { - location.X = world.width / 2; + location.X = Floor(world.width / 2); message.timeout -= interval; - location.VX = 0; + location.VX = Floor(0); } else if(world.phase == GamePhase.PLAYING) { location.VX = world.width; }