From b96566d0a0595e53d7e6544d1190801a26e61c6b Mon Sep 17 00:00:00 2001 From: cha0s Date: Wed, 3 Jul 2024 16:13:14 -0500 Subject: [PATCH] refactor: much improved collision --- app/create-homestead.js | 24 +++- app/create-house.js | 14 ++- app/create-player.js | 14 ++- app/ecs-components/collider.js | 31 +++-- app/ecs-components/position.js | 32 +++-- app/ecs-systems/colliders.js | 32 +++-- app/ecs-systems/visible-aabbs.js | 21 +--- app/react-components/entity.jsx | 44 ++++--- app/util/math.js | 4 + app/util/spatial-hash.js | 81 +++++++++---- app/util/spatial-hash.test.js | 125 ++++++++++++++++++++ public/assets/house/collision-start.js | 2 +- public/assets/shit-shack/collision-start.js | 27 +++-- public/assets/tomato-seeds/start.js | 14 ++- 14 files changed, 334 insertions(+), 131 deletions(-) create mode 100644 app/util/spatial-hash.test.js diff --git a/app/create-homestead.js b/app/create-homestead.js index 4cde265..3599396 100644 --- a/app/create-homestead.js +++ b/app/create-homestead.js @@ -21,12 +21,24 @@ export default async function createHomestead(Ecs) { await ecs.create({ Collider: { bodies: [ - [ - {x: -36, y: -16}, - {x: -21, y: -16}, - {x: -36, y: -1}, - {x: -21, y: -1}, - ], + { + points: [ + {x: -36, y: 8}, + {x: -21, y: 8}, + {x: -36, y: 17}, + {x: -21, y: 17}, + ], + tags: ['door'], + }, + { + impassable: 1, + points: [ + {x: -52, y: -16}, + {x: 48, y: -16}, + {x: -52, y: 15}, + {x: 48, y: 15}, + ], + }, ], collisionStartScript: '/assets/shit-shack/collision-start.js', }, diff --git a/app/create-house.js b/app/create-house.js index 7819f5d..042cde6 100644 --- a/app/create-house.js +++ b/app/create-house.js @@ -20,12 +20,14 @@ export default async function createHouse(Ecs) { await ecs.create({ Collider: { bodies: [ - [ - {x: -8, y: -8}, - {x: 7, y: -8}, - {x: 7, y: 7}, - {x: -8, y: 7}, - ], + { + points: [ + {x: -8, y: -8}, + {x: 7, y: -8}, + {x: 7, y: 7}, + {x: -8, y: 7}, + ], + }, ], collisionStartScript: '/assets/house/collision-start.js', }, diff --git a/app/create-player.js b/app/create-player.js index 687983c..20fd8c7 100644 --- a/app/create-player.js +++ b/app/create-player.js @@ -3,12 +3,14 @@ export default async function createPlayer(id) { Camera: {}, Collider: { bodies: [ - [ - {x: -8, y: -8}, - {x: 7, y: -8}, - {x: -8, y: 7}, - {x: 7, y: 7}, - ], + { + points: [ + {x: -8, y: -8}, + {x: 7, y: -8}, + {x: 7, y: 7}, + {x: -8, y: 7}, + ], + }, ], }, Controlled: {}, diff --git a/app/ecs-components/collider.js b/app/ecs-components/collider.js index ab322b4..f170ca4 100644 --- a/app/ecs-components/collider.js +++ b/app/ecs-components/collider.js @@ -11,17 +11,20 @@ export default class Collider extends Component { isCollidingWith(other) { const {aabb, aabbs} = this; const {aabb: otherAabb, aabbs: otherAabbs} = other; + const intersections = []; if (!intersects(aabb, otherAabb)) { - return false; + return intersections; } - for (const aabb of aabbs) { - for (const otherAabb of otherAabbs) { + for (const i in aabbs) { + const aabb = aabbs[i]; + for (const j in otherAabbs) { + const otherAabb = otherAabbs[j]; if (intersects(aabb, otherAabb)) { - return true; + intersections.push([this.bodies[i], other.bodies[j]]); } } } - return false; + return intersections; } isWithin(query) { const {aabb, aabbs} = this; @@ -40,9 +43,9 @@ export default class Collider extends Component { this.aabb = {x0: Infinity, x1: -Infinity, y0: Infinity, y1: -Infinity}; this.aabbs = []; const {bodies} = this; - for (const points of bodies) { + for (const body of bodies) { let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity; - for (const point of points) { + for (const point of body.points) { const x = point.x + px; const y = point.y + py; if (x < x0) x0 = x; @@ -88,8 +91,18 @@ export default class Collider extends Component { bodies: { type: 'array', subtype: { - type: 'array', - subtype: vector2d('int16'), + type: 'object', + properties: { + impassable: {type: 'uint8'}, + points: { + type: 'array', + subtype: vector2d('int16'), + }, + tags: { + type: 'array', + subtype: {type: 'string'}, + }, + }, }, }, collisionEndScript: {type: 'string'}, diff --git a/app/ecs-components/position.js b/app/ecs-components/position.js index 9cf92eb..2e6bdd9 100644 --- a/app/ecs-components/position.js +++ b/app/ecs-components/position.js @@ -2,20 +2,34 @@ import Component from '@/ecs/component.js'; export default class Position extends Component { instanceFromSchema() { - const Instance = super.instanceFromSchema(); - const Component = this; - Object.defineProperty(Instance.prototype, 'tile', { - get: function () { - const {TileLayers} = Component.ecs.get(1); - const {Position: {x, y}} = Component.ecs.get(this.entity); + const {ecs} = this; + return class PositionInstance extends super.instanceFromSchema() { + lastX; + lastY; + get x() { + return super.x; + } + set x(x) { + this.lastX = super.x; + super.x = x; + } + get y() { + return super.y; + } + set y(y) { + this.lastY = super.y; + super.y = y; + } + get tile() { + const {TileLayers} = ecs.get(1); + const {Position: {x, y}} = ecs.get(this.entity); const {tileSize} = TileLayers.layers[0]; return { x: (x - (x % tileSize.x)) / tileSize.x, y: (y - (y % tileSize.y)) / tileSize.y, } - }, - }); - return Instance; + } + }; } static properties = { x: {type: 'float32'}, diff --git a/app/ecs-systems/colliders.js b/app/ecs-systems/colliders.js index cbeba9d..40f24d2 100644 --- a/app/ecs-systems/colliders.js +++ b/app/ecs-systems/colliders.js @@ -61,19 +61,30 @@ export default class Colliders extends System { continue; } delete other.Collider.collidingWith[entity.id]; - if (entity.Collider.isCollidingWith(other.Collider)) { + const intersections = entity.Collider.isCollidingWith(other.Collider); + if (intersections.length > 0) { entity.Collider.collidingWith[other.id] = true; other.Collider.collidingWith[entity.id] = true; if (!wasCollidingWith[other.id]) { if (entity.Collider.collisionStartScriptInstance) { + entity.Collider.collisionStartScriptInstance.context.intersections = intersections; entity.Collider.collisionStartScriptInstance.context.other = other; Ticking.addTickingPromise(entity.Collider.collisionStartScriptInstance.tickingPromise()); } if (other.Collider.collisionStartScriptInstance) { + other.Collider.collisionStartScriptInstance.context.intersections = intersections + .map(([l, r]) => [r, l]); other.Collider.collisionStartScriptInstance.context.other = entity; Ticking.addTickingPromise(other.Collider.collisionStartScriptInstance.tickingPromise()); } } + for (const [, {impassable}] of intersections) { + if (impassable) { + entity.Position.x = entity.Position.lastX + entity.Position.y = entity.Position.lastY + break; + } + } } } for (const otherId in wasCollidingWith) { @@ -93,24 +104,9 @@ export default class Colliders extends System { } within(query) { - const {x0, x1, y0, y1} = query; - const [cx0, cy0] = this.hash.chunkIndex(x0, y0); - const [cx1, cy1] = this.hash.chunkIndex(x1, y1); - const seen = {}; const within = new Set(); - for (let cy = cy0; cy <= cy1; ++cy) { - for (let cx = cx0; cx <= cx1; ++cx) { - for (const id of this.hash.chunks[cx][cy]) { - if (seen[id]) { - continue; - } - seen[id] = true; - const entity = this.ecs.get(id); - if (entity.Collider.isWithin(query)) { - within.add(entity); - } - } - } + for (const id of this.hash.within(query)) { + within.add(this.ecs.get(id)); } return within; } diff --git a/app/ecs-systems/visible-aabbs.js b/app/ecs-systems/visible-aabbs.js index 6c45295..497228b 100644 --- a/app/ecs-systems/visible-aabbs.js +++ b/app/ecs-systems/visible-aabbs.js @@ -1,5 +1,4 @@ import {System} from '@/ecs/index.js'; -import {intersects} from '@/util/math.js'; import SpatialHash from '@/util/spatial-hash.js'; export default class VisibleAabbs extends System { @@ -33,6 +32,7 @@ export default class VisibleAabbs extends System { updateHash(entity) { if (!entity.VisibleAabb) { + this.hash.remove(entity.id); return; } this.hash.update(entity.VisibleAabb, entity.id); @@ -63,24 +63,9 @@ export default class VisibleAabbs extends System { } within(query) { - const {x0, x1, y0, y1} = query; - const [cx0, cy0] = this.hash.chunkIndex(x0, y0); - const [cx1, cy1] = this.hash.chunkIndex(x1, y1); - const seen = {}; const within = new Set(); - for (let cy = cy0; cy <= cy1; ++cy) { - for (let cx = cx0; cx <= cx1; ++cx) { - for (const id of this.hash.chunks[cx][cy]) { - if (seen[id]) { - continue; - } - seen[id] = true; - const entity = this.ecs.get(id); - if (intersects(query, entity.VisibleAabb)) { - within.add(entity); - } - } - } + for (const id of this.hash.within(query)) { + within.add(this.ecs.get(id)); } return within; } diff --git a/app/react-components/entity.jsx b/app/react-components/entity.jsx index 0f7f8da..b6124f7 100644 --- a/app/react-components/entity.jsx +++ b/app/react-components/entity.jsx @@ -7,18 +7,18 @@ import {useMainEntity} from '@/context/main-entity.js'; import Emitter from './emitter.jsx'; import Sprite from './sprite.jsx'; -function Aabb({color, x0, y0, x1, y1}) { +function Aabb({color, width = 0.5, x0, y0, x1, y1, ...rest}) { const draw = useCallback((g) => { g.clear(); - g.lineStyle(0.5, color); + g.lineStyle(width, color); g.moveTo(x0, y0); - g.lineTo(x1, y0); - g.lineTo(x1, y1); - g.lineTo(x0, y1); + g.lineTo(x1 + 1, y0); + g.lineTo(x1 + 1, y1 + 1); + g.lineTo(x0, y1 + 1); g.lineTo(x0, y0); - }, [color, x0, x1, y0, y1]); + }, [color, width, x0, x1, y0, y1]); return ( - + ); } @@ -51,6 +51,9 @@ function Entity({entity, ...rest}) { if (!entity) { return false; } + if (debug) { + entity.Collider.recalculateAabbs(); + } return ( )} {debug && entity.Collider && ( - + <> + + {entity.Collider.aabbs.map((aabb, i) => ( + + ))} + )} {debug && mainEntity == entity.id && ( ( Array(Math.ceil(this.area.y / this.chunkSize.y)) .fill(0) - .map(() => []) + .map(() => new Map()) )); - this.data = {}; - } - - clamp(x, y) { - return [ - Math.max(0, Math.min(x, this.area.x - 1)), - Math.max(0, Math.min(y, this.area.y - 1)) - ]; + this.data = new Map(); } chunkIndex(x, y) { - const [cx, cy] = this.clamp(x, y); - return [ - Math.floor(cx / this.chunkSize.x), - Math.floor(cy / this.chunkSize.y), - ]; + return { + x: clamp(Math.floor(x / this.chunkSize.x), 0, this.chunks.length - 1), + y: clamp(Math.floor(y / this.chunkSize.y), 0, this.chunks[0].length - 1), + }; } remove(datum) { - if (datum in this.data) { - for (const [cx, cy] of this.data[datum]) { - const chunk = this.chunks[cx][cy]; - chunk.splice(chunk.indexOf(datum), 1); - } + if (!this.data.has(datum)) { + return; } - this.data[datum] = []; + for (const {x, y} of this.data.get(datum).chunks) { + this.chunks[x][y].delete(datum); + } + this.data.delete(datum); } update({x0, x1, y0, y1}, datum) { this.remove(datum); - for (const [x, y] of [[x0, y0], [x0, y1], [x1, y0], [x1, y1]]) { - const [cx, cy] = this.chunkIndex(x, y); - this.data[datum].push([cx, cy]); - this.chunks[cx][cy].push(datum); + const [sx0, sx1] = x0 < x1 ? [x0, x1] : [x1, x0]; + const [sy0, sy1] = y0 < y1 ? [y0, y1] : [y1, y0]; + const {x: cx0, y: cy0} = this.chunkIndex(sx0, sy0); + const {x: cx1, y: cy1} = this.chunkIndex(sx1, sy1); + const chunks = []; + for (let iy = cy0; iy <= cy1; ++iy) { + for (let ix = cx0; ix <= cx1; ++ix) { + const chunk = this.chunks[ix][iy]; + if (!chunk.has(datum)) { + chunk.set(datum, true); + } + chunks.push({x: ix, y: iy}); + } } + this.data.set( + datum, + { + bounds: {x0: sx0, x1: sx1, y0: sy0, y1: sy1}, + chunks, + }, + ); + } + + within(query) { + const {x0, x1, y0, y1} = query; + const [sx0, sx1] = x0 < x1 ? [x0, x1] : [x1, x0]; + const [sy0, sy1] = y0 < y1 ? [y0, y1] : [y1, y0]; + const {x: cx0, y: cy0} = this.chunkIndex(sx0, sy0); + const {x: cx1, y: cy1} = this.chunkIndex(sx1, sy1); + const candidates = new Set(); + const within = new Set(); + for (let cy = cy0; cy <= cy1; ++cy) { + for (let cx = cx0; cx <= cx1; ++cx) { + for (const [datum] of this.chunks[cx][cy]) { + candidates.add(datum); + } + } + } + for (const datum of candidates) { + if (intersects(this.data.get(datum).bounds, query)) { + within.add(datum); + } + } + return within; } } diff --git a/app/util/spatial-hash.test.js b/app/util/spatial-hash.test.js new file mode 100644 index 0000000..bfdebb5 --- /dev/null +++ b/app/util/spatial-hash.test.js @@ -0,0 +1,125 @@ +import {expect, test} from 'vitest'; + +import SpatialHash from './spatial-hash.js'; + +test('creates chunks', async () => { + const hash = new SpatialHash({x: 128, y: 128}); + expect(hash.chunks.length) + .to.equal(2); + expect(hash.chunks[0].length) + .to.equal(2); + expect(hash.chunks[1].length) + .to.equal(2); +}); + +test('clamps to actual chunks', async () => { + const hash = new SpatialHash({x: 640, y: 640}); + expect(hash.chunkIndex(0, 0)) + .to.deep.equal({x: 0, y: 0}); + expect(hash.chunkIndex(320, 320)) + .to.deep.equal({x: 5, y: 5}); + expect(hash.chunkIndex(1280, 1280)) + .to.deep.equal({x: 9, y: 9}); +}); + +test('updates with data', async () => { + const hash = new SpatialHash({x: 640, y: 640}); + hash.update({x0: 32, x1: 96, y0: 32, y1: 96}, 'foobar'); + expect(hash.data.get('foobar')) + .to.deep.equal({ + bounds: {x0: 32, x1: 96, y0: 32, y1: 96}, + chunks: [ + {x: 0, y: 0}, + {x: 1, y: 0}, + {x: 0, y: 1}, + {x: 1, y: 1}, + ], + }); + expect(Array.from(hash.chunks[0][0])) + .to.deep.equal([['foobar', true]]); + expect(Array.from(hash.chunks[1][0])) + .to.deep.equal([['foobar', true]]); + expect(Array.from(hash.chunks[1][1])) + .to.deep.equal([['foobar', true]]); + expect(Array.from(hash.chunks[0][1])) + .to.deep.equal([['foobar', true]]); + hash.update({x0: 48, x1: 32, y0: 32, y1: 96}, 'foobar'); + expect(hash.data.get('foobar')) + .to.deep.equal({ + bounds: {x0: 32, x1: 48, y0: 32, y1: 96}, + chunks: [ + {x: 0, y: 0}, + {x: 0, y: 1}, + ], + }); + expect(Array.from(hash.chunks[0][0])) + .to.deep.equal([['foobar', true]]); + expect(Array.from(hash.chunks[1][0])) + .to.deep.equal([]); + expect(Array.from(hash.chunks[1][1])) + .to.deep.equal([]); + expect(Array.from(hash.chunks[0][1])) + .to.deep.equal([['foobar', true]]); + hash.update({x0: 32, x1: 160, y0: 32, y1: 160}, 'foobar'); + expect(hash.data.get('foobar')) + .to.deep.equal({ + bounds: {x0: 32, x1: 160, y0: 32, y1: 160}, + chunks: [ + {x: 0, y: 0}, + {x: 1, y: 0}, + {x: 2, y: 0}, + {x: 0, y: 1}, + {x: 1, y: 1}, + {x: 2, y: 1}, + {x: 0, y: 2}, + {x: 1, y: 2}, + {x: 2, y: 2}, + ], + }); + expect(Array.from(hash.chunks[0][0])) + .to.deep.equal([['foobar', true]]); + expect(Array.from(hash.chunks[1][0])) + .to.deep.equal([['foobar', true]]); + expect(Array.from(hash.chunks[2][0])) + .to.deep.equal([['foobar', true]]); + expect(Array.from(hash.chunks[3][0])) + .to.deep.equal([]); + expect(Array.from(hash.chunks[0][1])) + .to.deep.equal([['foobar', true]]); + expect(Array.from(hash.chunks[1][1])) + .to.deep.equal([['foobar', true]]); + expect(Array.from(hash.chunks[2][1])) + .to.deep.equal([['foobar', true]]); + expect(Array.from(hash.chunks[3][1])) + .to.deep.equal([]); + expect(Array.from(hash.chunks[0][2])) + .to.deep.equal([['foobar', true]]); + expect(Array.from(hash.chunks[1][2])) + .to.deep.equal([['foobar', true]]); + expect(Array.from(hash.chunks[2][2])) + .to.deep.equal([['foobar', true]]); + expect(Array.from(hash.chunks[3][2])) + .to.deep.equal([]); + expect(Array.from(hash.chunks[3][3])) + .to.deep.equal([]); + expect(Array.from(hash.chunks[3][3])) + .to.deep.equal([]); + expect(Array.from(hash.chunks[3][3])) + .to.deep.equal([]); + expect(Array.from(hash.chunks[3][3])) + .to.deep.equal([]); +}); + +test('queries for data', async () => { + const hash = new SpatialHash({x: 640, y: 640}); + hash.update({x0: 32, x1: 96, y0: 32, y1: 96}, 'foobar'); + expect(Array.from(hash.within({x0: 0, x1: 16, y0: 0, y1: 16}))) + .to.deep.equal([]); + expect(Array.from(hash.within({x0: 0, x1: 48, y0: 0, y1: 48}))) + .to.deep.equal(['foobar']); + expect(Array.from(hash.within({x0: 48, x1: 64, y0: 48, y1: 64}))) + .to.deep.equal(['foobar']); + hash.update({x0: 32, x1: 160, y0: 32, y1: 160}, 'foobar'); + expect(Array.from(hash.within({x0: 80, x1: 90, y0: 80, y1: 90}))) + .to.deep.equal(['foobar']); +}); diff --git a/public/assets/house/collision-start.js b/public/assets/house/collision-start.js index 876585b..5c71642 100644 --- a/public/assets/house/collision-start.js +++ b/public/assets/house/collision-start.js @@ -4,7 +4,7 @@ ecs.switchEcs( { Position: { x: 74, - y: 108, + y: 128, }, }, ); diff --git a/public/assets/shit-shack/collision-start.js b/public/assets/shit-shack/collision-start.js index 7e7b678..beaf9b3 100644 --- a/public/assets/shit-shack/collision-start.js +++ b/public/assets/shit-shack/collision-start.js @@ -1,10 +1,17 @@ -ecs.switchEcs( - other, - entity.Ecs.path, - { - Position: { - x: 72, - y: 304, - }, - }, -); +for (let i = 0; i < intersections.length; ++i) { + if (intersections[i][0].tags) { + if (intersections[i][0].tags.includes('door')) { + ecs.switchEcs( + other, + entity.Ecs.path, + { + Position: { + x: 72, + y: 304, + }, + }, + ); + } + } +} + diff --git a/public/assets/tomato-seeds/start.js b/public/assets/tomato-seeds/start.js index e81f7d0..2b3ed4a 100644 --- a/public/assets/tomato-seeds/start.js +++ b/public/assets/tomato-seeds/start.js @@ -11,12 +11,14 @@ if (projected?.length > 0) { const plant = { Collider: { bodies: [ - [ - {x: -8, y: -8}, - {x: 7, y: -8}, - {x: -8, y: 7}, - {x: 7, y: 7}, - ], + { + points: [ + {x: -8, y: -8}, + {x: 7, y: -8}, + {x: -8, y: 7}, + {x: 7, y: 7}, + ], + }, ], }, Interactive: {