diff --git a/chunks.md b/chunks.md new file mode 100644 index 0000000..aaa077e --- /dev/null +++ b/chunks.md @@ -0,0 +1,105 @@ +# Chunk System + +## Intro + +In order to increase the efficiency of the simulation, the screen is divided into square areas called chunks. The chunk allocation is handled by the `ChunkManager` class defined in `src/chunk.js`. The `ChunkManager` allocates an array of `Chunk`s, which are all processed and rendered only when required. The `ChunkManager` class provides a high level interface into the underlying chunks, providing basic functionality such as setting particles, rendering chunks and other utilty functions. + +## The Chunk Lifecycle + +After being created, the chunk is marked as inactive. There only 2 ways for a chunk to become active. Either: +
A particle enters the chunk, via the player or from another chunk
+Or
+A neighbouring chunk activates the chunk due to a particle on the border
+ +In either case, all the of the particles within the chunk will be processed. If any updates are encountered (a particle moving, being destroyed or created), the chunk will be marked as updated and will be rendered to the screen. If no updates are encountered, the chunk is marked as inactive again. + +## Particle / Chunk interface + +Whenever a chunk is processed, the `update()` function is called for all particles within that chunk. `update()` is called with the particle's coordinates relative to its containing chunk and a reference to the containing chunk, eg `update(0, 0, Chunk())`. The coordinates 0, 0 refer to the top left particle within a chunk. Particles should perform any processing, using `chunk.getRelative()` to query surrounding particles, and return an array of Update lists. There is a `ParticleUpdate` class in `src/lib/update.js` which assists with generating this array. Each item in the array should be another array where the first two items are an X and Y coordinate (relative to the parent chunk), and the final item is the new particle to be placed at those coordinates. Dispatching the updates is handled by the `Chunk` class and particles should not modify their parent chunk. + +### Example Snippets + +```js +// Basic particle that moves down if it can +class BasicParticle extends Particle { + constructor() { + const type = "basic"; + super(type, false); + } + + update(x, y, chunk) { + // Get the particle at (x, y + 1), ie the particle below + const particleBelow = chunk.getRelative(x, y + 1); + if (particleBelow.type === "air") { + return [ + [x, y, new Air()], // Replace the current particle with Air + [x, y + 1, this], // Replace the Air below with the current particle + ] + } + + return []; + } +} + +``` + +## Chunk + +```js +class Chunk { + constructor(x, y, chunkSize, manager) { + this.x = x; + this.y = y; + this.manager = manager; + this.particleX = x * chunkSize; + this.particleY = y * chunkSize; + this.chunkSize = chunkSize; + this.totalParticles = chunkSize * chunkSize; + this.particles = Array(this.totalParticles).fill(new Air()) + this.neighbours = Array(9).fill(null); + }; + + markActive(); + markInactive(); + markUpdated(); + draw(p, particleSize); + process(); + getChunkForRelative(x, y); + getRelative(x, y); + setIndex(index, particle); + setRelative(x, y, particle); + setAbsolute(x, y, particle); + registerNeighbour(neighbourIndex, chunk); + activateChunksNeighbouringParticle(index); +} +``` + +### Properties: +- `x`: The chunk x coordinate (in chunk coordinates). +- `y`: The chunk y coordinate (in chunk coordinates). +- `manager`: A reference to the `ChunkManager` that created/manages this chunk. +- `particleX`: The particle x coordinate of the top left corner of this chunk. +- `particleY`: The particle y coordinate of the top left corner of this chunk. +- `chunkSize`: The width and height of this chunk in particles. +- `totalParticles`: The total number of particles in this chunk. +- `particles`: The array of particles for this chunk. +- `neighbours`: The array of neighbouring chunks. + +### Methods: +- `constructor(x, y, chunkSize, manager)`: When the `ChunkManager` initialises a new `Chunk`, it will pass in the x and y position of the chunk (**in chunk coordinates**), the chunkSize which represents the width and height of the chunk, and a reference to itself. +- `markActive()`: When a chunk needs to be processed, it can call this function to signal to the `ChunkManager` that it should be processed on the next simulation step. It will continue to be processed on each simulation step until it calls `markInactive()`. +- `markInactive()`: This function signals to the `ChunkManager` that this chunk can be removed from the list of chunks to process. +- `markUpdated()`: This signals to the `ChunkManager` that this chunk needs to be re-rendered. +- `draw(p, particleSize)`: This function renders the chunk to the screen. It takes two arguments: `p`, which is a reference to the p5 object, and `particleSize` which is an integer specifying what size to draw the particles within the chunk. +- `process()`: This function invokes the `update()` method on all particles within the chunk and applies the updates to the particles array of this chunk and its neighbours if required. +- `getChunkForRelative(x, y)`: This function locates the chunk that contains the specified coordinates relative to the top left corner of the current chunk (measured in particles). eg, with a chunkSize of 8, calling `getChunkForRelative(-1, 9)` will return the chunk to the bottom-left of the current chunk. When possible, this function will use the neighbours array of the chunk, however if the requested coordinates are more than 1 chunk away, the chunk will be located using the `getChunkFor(x, y)` method of the `ChunkManager`. +- `getRelative(x, y)`: Returns the particle at the given relative (particle) coordinates. +- `setIndex(index, particle)`: Sets the particle at the given index and marks the chunk updated and active. Also calls `activateChunksNeighbouringParticle(index)`. +- `setRelatve(x, y, particle)`: Sets the particle at the given relative (particle) coordinates. +- `setAbsolute(x, y, particle)`: Sets the particle at the given absolute (particle) coordinates. +- `registerNeighbour(neighbourIndex, chunk)`: This function is called by the `ChunkManager` when intialising chunks. A neighbourIndex must be specified from the `ChunkNeighbourEnum` class, as well as the chunk to associate with the given index. +- `activateChunksNeighbouringParticle(index)`: For a given index, this function will determine if the particle directly neighbours any chunks, and if so, will mark them as active. + + +## ChunkManager + diff --git a/package.json b/package.json index 68280bf..51d1bde 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sand-game2", - "packageManager": "yarn@3.6.0", + "packageManager": "yarn@3.6.1", "main": "server.js", "dependencies": { "express": "^4.18.2", diff --git a/src/chunk.js b/src/chunk.js new file mode 100644 index 0000000..9927cd3 --- /dev/null +++ b/src/chunk.js @@ -0,0 +1,364 @@ +"use strict"; + +import Air from "./particles/air"; +import { coordPairToIndex, indexToCoordPair } from "./lib/coords" +import ParticleUpdate from "./lib/update"; + +class ChunkNeighbourEnum { + static TOP_LEFT = 0; + static TOP_CENTRE = 1; + static TOP_RIGHT = 2; + static MIDDLE_LEFT = 3; + static MIDDLE_RIGHT = 5; + static BOTTOM_LEFT = 6; + static BOTTOM_CENTRE = 7; + static BOTTOM_RIGHT = 8; +} + +class Chunk { + constructor(x, y, chunkSize, manager) { + this.x = x; + this.y = y; + this.manager = manager; + this.particleX = x * chunkSize; + this.particleY = y * chunkSize; + this.chunkSize = chunkSize; + this.totalParticles = chunkSize * chunkSize; + this.particles = Array(this.totalParticles).fill(new Air()) + this.neighbours = Array(9).fill(null); + this.updateQueue = new Set(); + } + + markActive() { + this.manager.registerActiveChunk(this); + } + + markInactive() { + this.manager.registerInactiveChunk(this); + } + + markAddtional() { + this.manager.registerAdditionalChunk(this); + } + + markUpdated() { + this.manager.registerUpdatedChunk(this); + } + + draw(p, particleSize) { + p.push(); + p.scale(particleSize); + p.translate(this.particleX, this.particleY); + this.particles.forEach((particle, index) => { + p.fill(...particle.colour); + p.square(...indexToCoordPair(index, this.chunkSize), 1); + }); + p.pop(); + } + + process() { + let updateCount = 0; + const particlesCopy = [...this.particles]; + particlesCopy.forEach((particle, index) => { + // static particles have no updates + if (particle.static) return; + + // get list of updates from particle + const updates = particle.process(...indexToCoordPair(index, this.chunkSize), this); + if (updates === ParticleUpdate.NullUpdate) { + updateCount++; + return; + } + + updateCount += updates.length; + + for (const update of updates) { + const [x, y, updatedParticle] = update; + + // index is within current chunk + if (0 <= x && x < this.chunkSize && 0 <= y && y < this.chunkSize) { + this.setRelative(x, y, updatedParticle); + continue; + } + + const chunk = this.getChunkForRelative(x, y); + if (chunk === null) return; + + // generate coords relative to other chunk + const relativeX = (x + this.particleX) - chunk.particleX; + const relativeY = (y + this.particleY) - chunk.particleY; + + if (this.manager.chunkHasBeenProcessed(chunk)) { + chunk.setRelative(relativeX, relativeY, updatedParticle); + } else { + if (!this.manager.chunkIsActive(chunk)) + chunk.markAddtional(); + + chunk.setRelative(relativeX, relativeY, updatedParticle.withUpdateCooldown(1)); + } + } + }); + + for (const update of this.updateQueue) { + const [x, y, updatedParticle] = update; + this.setRelative(x, y, updatedParticle); + } + this.updateQueue.clear(); + + if (updateCount === 0) + this.markInactive(); + else + this.markUpdated(); + } + + getChunkForRelative(x, y) { + /* + This function takes a pair of relative coordinates (ie (0,0) is the top left corner of the chunk) + and returns the chunk corresponding to the coordinates. If the coordinate falls within a neighbouring + chunk, the neighbouring chunk will be returned from this.neighbours. If the chunk is further afield, + this.manager.getChunkFor() will be called with the absolute coordinates to resolve the chunk. This case + shouldn't happen in the current state of PixelPlayground as no particles currently have the ability to + affect particles more than 1 chunk away, but the implementation may be useful in the future. + */ + + // Work out what column the chunk is in + let col = 0; + if (-this.chunkSize <= x && x < 0) + col = 0; // left + else if (0 <= x && x < this.chunkSize) + col = 1; // centre + else if (this.chunkSize <= x < (this.chunkSize * 2)) + col = 2; // right + else + return this.manager.getChunkFor(x + this.particleX, y + this.particleY); + + // Work out what row the chunk is in + let row = 0; + if (-this.chunkSize <= y && y < 0) + row = 0; // top + else if (0 <= y && y < this.chunkSize) + row = 3; // middle + else if (this.chunkSize <= x < (this.chunkSize * 2)) + row = 6; // bottom + else + return this.manager.getChunkFor(x + this.particleX, y + this.particleY); + + + const index = row + col; + if (index === 4) + // 4 => column = 1 (centre) + row = 3 (middle) => the current chunk + return this; + + return this.neighbours[index]; + } + + setIndex(index, particle) { + this.particles[index] = particle; + this.markActive(); + this.markUpdated(); + this.activateChunksNeighbouringParticle(index); + } + + getRelative(x, y) { + if (0 <= x && x < this.chunkSize && 0 <= y && y < this.chunkSize) + return this.particles[coordPairToIndex(x, y, this.chunkSize)]; + + const chunk = this.getChunkForRelative(x, y); + if (chunk === null) return null; + // generate coords relative to other chunk + const relativeX = (x + this.particleX) - chunk.particleX; + const relativeY = (y + this.particleY) - chunk.particleY; + + return chunk.getRelative(relativeX, relativeY); + } + + setRelative(x, y, particle) { + this.setIndex(coordPairToIndex(x, y, this.chunkSize), particle); + } + + setAbsolute(x, y, particle) { + this.setIndex(coordPairToIndex(x - this.particleX, y - this.particleY, this.chunkSize), particle); + } + + registerNeighbour(neighbourIndex, chunk) { + this.neighbours[neighbourIndex] = chunk; + } + + activateChunksNeighbouringParticle(index) { + // This math can probably be sped up by not converting to a coord pair + // Eg. + // index % chunkSize == 0 -> It is on the left wall + // (index + 1) % chunkSize == 0 -> It is on the right wall + // index < chunkSize -> It is on the top row + // index >= this.totalParticles - chunkSize -> It is on the bottom row + const [x, y] = indexToCoordPair(index, this.chunkSize); + + let x_corner = null, y_corner = null; + if (x === 0) { + this.neighbours[ChunkNeighbourEnum.MIDDLE_LEFT]?.markActive(); + x_corner = 0; + } else if (x === this.chunkSize - 1) { + this.neighbours[ChunkNeighbourEnum.MIDDLE_RIGHT]?.markActive(); + x_corner = 2; + } + if (y === 0) { + this.neighbours[ChunkNeighbourEnum.TOP_CENTRE]?.markActive(); + y_corner = 0; + } else if (y === this.chunkSize - 1) { + this.neighbours[ChunkNeighbourEnum.BOTTOM_CENTRE]?.markActive(); + y_corner = 6; + } + + if (x_corner !== null && y_corner !== null) + this.neighbours[x_corner + y_corner]?.markActive(); + } +} + +class ChunkManager { + constructor(cols, rows, chunkSize) { + this.cols = cols; + this.rows = rows; + + this.chunkCount = cols * rows; + this.chunkSize = chunkSize; + + this.chunks = new Array(this.cols * this.rows); + for (let x = 0; x < cols; x++) + for (let y = 0; y < rows; y++) + this.chunks[y * cols + x] = new Chunk(x, y, chunkSize, this); + + this.activeChunks = new Set(); + this.updatedChunks = new Set(); + this.additionalChunks = new Set(); + + this.chunkNeighbourMapping = { + [ChunkNeighbourEnum.TOP_LEFT]: -this.cols - 1, + [ChunkNeighbourEnum.TOP_CENTRE]: -this.cols, + [ChunkNeighbourEnum.TOP_RIGHT]: -this.cols + 1, + [ChunkNeighbourEnum.MIDDLE_LEFT]: -1, + [ChunkNeighbourEnum.MIDDLE_RIGHT]: 1, + [ChunkNeighbourEnum.BOTTOM_LEFT]: this.cols - 1, + [ChunkNeighbourEnum.BOTTOM_CENTRE]: this.cols, + [ChunkNeighbourEnum.BOTTOM_RIGHT]: this.cols + 1, + }; + + this.chunks.forEach((chunk, chunkIndex, chunksArray) => { + const [x, y] = indexToCoordPair(chunkIndex, cols); + + // Crude way to make a shallow copy + const mapping = JSON.parse(JSON.stringify(this.chunkNeighbourMapping)); + + if (x === 0) { + delete mapping[ChunkNeighbourEnum.TOP_LEFT]; + delete mapping[ChunkNeighbourEnum.MIDDLE_LEFT]; + delete mapping[ChunkNeighbourEnum.BOTTOM_LEFT]; + } + if (x === cols - 1) { + delete mapping[ChunkNeighbourEnum.TOP_RIGHT]; + delete mapping[ChunkNeighbourEnum.MIDDLE_RIGHT]; + delete mapping[ChunkNeighbourEnum.BOTTOM_RIGHT]; + } + + if (y === 0) { + delete mapping[ChunkNeighbourEnum.TOP_LEFT]; + delete mapping[ChunkNeighbourEnum.TOP_CENTRE]; + delete mapping[ChunkNeighbourEnum.TOP_RIGHT]; + } + if (y === rows - 1) { + delete mapping[ChunkNeighbourEnum.BOTTOM_LEFT]; + delete mapping[ChunkNeighbourEnum.BOTTOM_CENTRE]; + delete mapping[ChunkNeighbourEnum.BOTTOM_RIGHT]; + } + + for (const entry of Object.entries(mapping)) { + const [neighbourId, indexChange] = entry; + chunk.registerNeighbour(neighbourId, chunksArray[chunkIndex + indexChange]); + } + }); + + this.processedChunks = new Set(); + } + + set(x, y, particle) { + this.getChunkFor(x, y)?.setAbsolute(x, y, particle); + } + + registerActiveChunk(chunk) { + this.activeChunks.add(chunk); + } + + registerAdditionalChunk(chunk) { + this.additionalChunks.add(chunk); + } + + registerInactiveChunk(chunk) { + this.activeChunks.delete(chunk); + } + + registerUpdatedChunk(chunk) { + this.updatedChunks.add(chunk); + } + + getChunkFor(x, y) { + const chunkX = ~~(x / this.chunkSize); + const chunkY = ~~(y / this.chunkSize); + if (0 > chunkX || chunkX >= this.cols || 0 > chunkY || chunkY >= this.rows) + return null; + + const chunkIndex = coordPairToIndex( + ~~(x / this.chunkSize), + ~~(y / this.chunkSize), + this.cols + ); + return this.chunks[chunkIndex]; + } + + drawAllChunks(p, particleSize) { + for (const chunk of this.chunks) { + chunk.draw(p, particleSize); + } + } + + draw(p, particleSize) { + for (const chunk of this.updatedChunks) { + chunk.draw(p, particleSize); + // TODO: move this to the debug canvas + // chunk.debug(p); + } + + // Should be faster than instantiating a new Set due to GC and Heap alloc times + // https://measurethat.net/Benchmarks/Show/10675/0/new-set-vs-set-clear#latest_results_block + this.updatedChunks.clear(); + } + + process() { + // Clone the active chunks set to avoid modifying it + // while we are iterating over it - JS doesn't agree with that + const activeChunks = new Set(this.activeChunks); + this.processChunks(activeChunks); + + while (this.additionalChunks.size !== 0) { + const additionalChunks = new Set(this.additionalChunks); + this.additionalChunks.clear(); + this.processChunks(additionalChunks); + } + } + + processChunks(chunkArray) { + this.processedChunks.clear(); + for (const chunk of chunkArray) { + chunk.process(); + this.processedChunks.add(chunk); + } + } + + chunkHasBeenProcessed(chunk) { + return this.processedChunks.has(chunk); + } + + chunkIsActive(chunk) { + return this.activeChunks.has(chunk); + } +} + +export default ChunkManager; \ No newline at end of file diff --git a/src/debug.js b/src/debug.js new file mode 100644 index 0000000..56dcbe4 --- /dev/null +++ b/src/debug.js @@ -0,0 +1,269 @@ +import p5 from "p5" +import ChunkManager from "./chunk"; + +class Debug { + constructor(enabled, screen, p, config) { + this.enabled = enabled; + this.screen = screen; + this.p = p; + + this.overlayDiv = document.createElement("div"); + this.overlayDiv.style = "position:absolute;top:0;left:0;"; + document.body.appendChild(this.overlayDiv); + + this.mouseX = null; + this.mouseY = null; + + this.debugFrameCount = 0; + + this.framerateQueueLength = 30; + this.framerateQueue = Array(this.framerateQueueLength); + + this.debugCommand = false; + this.debugPause = false; + + this.config = config; + + const sketch = (pdbg) => { + this.pdbg = pdbg; + pdbg.setup = () => { + pdbg.noStroke(); + const {canvas} = pdbg.createCanvas(screen.pixelWidth, screen.pixelHeight); + canvas.addEventListener("contextmenu", e => e.preventDefault()); + }; + + pdbg.draw = () => { + this.debug(); + this.mouseX = pdbg.mouseX; + this.mouseY = pdbg.mouseY; + this.debugFrameCount++; + } + + pdbg.keyPressed = () => { + if (pdbg.keyCode === pdbg.CONTROL) + return this.toggle(); + + if (!this.enabled) return; + + if (pdbg.keyCode === pdbg.SHIFT) { + this.debugCommand = true; + return; + } + + if (!this.debugCommand) return; + + switch (pdbg.keyCode) { + case 67: // C + this.toggleOverlay("ChunkBorders"); + break; + case 80: // P + this.debugPause = !this.debugPause; + if (this.debugPause) + this.p.noLoop(); + else + this.p.loop(); + break; + case 82: // R + this.screen.chunks = new ChunkManager(this.screen.chunkWidth, this.screen.chunkHeight, this.config.chunkSize); + this.screen.framenum = 0; + this.screen.drawAll(); + // Hide cursor + for (const chunk of this.screen.getBrushChunks(this.mouseX / this.config.particleSize, this.mouseY / this.config.particleSize)) + chunk.draw(this.p, this.config.particleSize); + break; + case 83: // S + if (!this.debugPause) break; + this.p.redraw(); + + // Hide cursor + for (const chunk of this.screen.getBrushChunks(this.mouseX / this.config.particleSize, this.mouseY / this.config.particleSize)) + chunk.draw(this.p, this.config.particleSize); + break; + case 90: // Z + this.toggleOverlay("ChunkUpdates"); + break; + case pdbg.LEFT_ARROW: + this.config.framerate -= Math.min(5, this.config.framerate / 2); + this.p.frameRate(this.config.framerate); + break; + case pdbg.RIGHT_ARROW: + this.config.framerate += Math.min(5, this.config.framerate * 2); + this.p.frameRate(this.config.framerate); + break; + } + + return false; + } + + pdbg.keyReleased = () => { + if (pdbg.keyCode === pdbg.SHIFT) + this.debugCommand = false; + } + }; + + this.metrics = { + framenum: { + enabled: true, + fn: (dbg) => dbg.screen.framenum, + }, + framerate: { + enabled: true, + fn: (dbg) => { + let retval; + if (dbg.debugFrameCount % dbg.framerateQueueLength === 0) { + retval = (dbg.framerateQueue.reduce((a, v) => a + v, 0) / dbg.framerateQueueLength).toPrecision(4); + } else { + retval = dbg.metricValues.framerate + } + dbg.framerateQueue[dbg.debugFrameCount % dbg.framerateQueueLength] = dbg.p.getFrameRate(); + return retval; + } + }, + mouseX: { + enabled: true, + fn: (dbg) => dbg.mouseX, + }, + mouseY: { + enabled: true, + fn: (dbg) => dbg.mouseY, + }, + particle: { + enabled: true, + fn: (dbg) => `${~~(dbg.mouseX / dbg.config.particleSize)},${~~(dbg.mouseY / dbg.config.particleSize)}`, + }, + particleInChunk: { + enabled: true, + fn: (dbg) => `${~~(dbg.mouseX / dbg.config.particleSize) % dbg.config.chunkSize},${~~(dbg.mouseY / dbg.config.particleSize) % dbg.config.chunkSize}` + }, + chunk: { + enabled: true, + fn: (dbg) => `${~~(dbg.mouseX / (dbg.config.particleSize * dbg.config.chunkSize))},${~~(dbg.mouseY / (dbg.config.particleSize * dbg.config.chunkSize))}` + }, + particleType: { + enabled: true, + fn: (dbg) => { + const [particleX, particleY] = [~~(dbg.mouseX / dbg.config.particleSize), ~~(dbg.mouseY / dbg.config.particleSize)]; + const chunk = dbg.screen.chunks.getChunkFor(particleX, particleY); + return chunk?.getRelative(particleX - chunk.particleX, particleY - chunk.particleY)?.type; + } + } + } + + // Dict containing previous values for all metrics + this.metricValues = {}; + + + this.customOverlays = { + "ChunkUpdates": { + enabled: false, + fn: (dbg) => { + dbg.pdbg.push(); + dbg.pdbg.fill(0, 255, 0, 127); + for (const chunk of dbg.screen.chunks.activeChunks) { + dbg.pdbg.square(chunk.particleX * dbg.config.particleSize, chunk.particleY * dbg.config.particleSize, dbg.config.chunkSize * dbg.config.particleSize); + } + dbg.pdbg.fill(0, 0, 255, 127); + for (const chunk of dbg.screen.chunks.updatedChunks) { + dbg.pdbg.square(chunk.particleX * dbg.config.particleSize, chunk.particleY * dbg.config.particleSize, dbg.config.chunkSize * dbg.config.particleSize); + } + dbg.pdbg.pop(); + }, + }, + "ChunkBorders": { + enabled: false, + fn: (dbg) => { + const [chunkX, chunkY] = this.metricValues.chunk.split(","); + dbg.pdbg.push(); + dbg.pdbg.stroke(40); + dbg.pdbg.strokeWeight(dbg.config.particleSize / 2); + const chunkPixelSize = dbg.config.chunkSize * dbg.config.particleSize; + for (let x = 0; x < dbg.screen.chunks.cols; x++) { + for (let y = 0; y < dbg.screen.chunks.cols; y++) { + dbg.pdbg.noFill(); + if (x.toString() === chunkX || y.toString() === chunkY) + dbg.pdbg.fill(170, 170, 170, 127); + dbg.pdbg.square(x * chunkPixelSize, y * chunkPixelSize, chunkPixelSize); + } + } + dbg.pdbg.pop(); + } + }, + }; + + new p5(sketch, this.overlayDiv); + + if (!this.enabled) + this.disable() + } + + debug() { + this.pdbg.clear(); + this.renderOverlays(); + this.renderMetrics(); + } + + renderMetrics() { + this.pdbg.fill(255, 255, 255); + this.pdbg.textSize(20); + let ypos = 20; + for (const metric of Object.entries(this.metrics)) { + const [metricName, metricObject] = metric; + const {enabled, fn} = metricObject; + if (enabled) { + const metricValue = fn(this); + + this.metricValues[metricName] = metricValue; + this.pdbg.text(`${metricName}: ${metricValue}`, 10, ypos); + ypos += 20 + } + } + } + + renderOverlays() { + for (const overlay of Object.values(this.customOverlays)) { + const {fn, enabled} = overlay; + if (enabled) + fn(this); + } + } + + addOverlay(name, fn) { + this.customOverlays[name] = {fn, enabled: false}; + } + + toggleOverlay(name) { + this.customOverlays[name].enabled = !(this.customOverlays[name].enabled); + } + + addMetric(name, fn) { + this.metrics[name] = { + fn, + enabled: true, + } + } + + toggleMetric(name) { + this.metrics[name].enabled = !(this.metrics[name].enabled); + } + + toggle() { + if (this.enabled) + this.disable(); + else + this.enable(); + } + + enable() { + this.enabled = true; + this.pdbg.loop(); + this.overlayDiv.removeAttribute("hidden"); + } + + disable() { + this.enabled = false; + this.pdbg.noLoop(); + this.overlayDiv.setAttribute("hidden", true); + } +} + +export default Debug; \ No newline at end of file diff --git a/src/lib/coords.js b/src/lib/coords.js new file mode 100644 index 0000000..5d0a3d2 --- /dev/null +++ b/src/lib/coords.js @@ -0,0 +1,7 @@ +export function coordPairToIndex(x, y, width) { + return (y * width) + x; +} + +export function indexToCoordPair(index, width) { + return [(index % width), ~~(index / width)]; +} \ No newline at end of file diff --git a/src/lib/random.js b/src/lib/random.js new file mode 100644 index 0000000..59b93a7 --- /dev/null +++ b/src/lib/random.js @@ -0,0 +1,38 @@ +// Would be nice to have a seeded random number generator but JS doesn't play +// nice with big numbers so this is a WIP + +class RandomNumberGenerator { + constructor(seed = null) { + this.seed = BigInt(seed ?? this.getSeed()); + } + + getSeed() { + return ~~(Math.random() * 0xffff_ffff_ffff_ffffn) + } + + random() { + /* + JS Implementation of the following: + uint64_t wyhash64_x; + uint64_t wyhash64() { + wyhash64_x += 0x60bee2bee120fc15; + __uint128_t tmp; + tmp = (__uint128_t) wyhash64_x * 0xa3b195354a39b70d; + uint64_t m1 = (tmp >> 64) ^ tmp; + tmp = (__uint128_t)m1 * 0x1b03738712fad5c9; + uint64_t m2 = (tmp >> 64) ^ tmp; + return m2; + } + */ + this.seed += 0x60bee2bee120fc15n; + let tmp = this.seed * 0xa3b195354a39b70dn; + const m1 = (tmp >> 64) ^ tmp; + tmp = BigInt(m1) * 0x1b03738712fad5c9n; + const m2 = (tmp >> 64) ^ tmp; + return m2; + } + + randomBetween(start, end) { + return ~~(start + (this.random() / (end - start))) + } +} \ No newline at end of file diff --git a/src/lib/update.js b/src/lib/update.js new file mode 100644 index 0000000..7b0aba4 --- /dev/null +++ b/src/lib/update.js @@ -0,0 +1,69 @@ +class ParticleUpdate { + // Used by particle with an update cooldown to keep their chunk active + static NullUpdate = null; + + constructor(particle, x, y, chunk) { + this.particle = particle; + this.x = x; + this.y = y; + this.chunk = chunk; + this.updates = []; + this.loadedParticles = {}; + } + + getParticle(offsetX, offsetY) { + const position = [offsetX, offsetY]; + if (position in this.loadedParticles) + return this.loadedParticles[position]; + + const particle = this.chunk.getRelative(this.x + offsetX, this.y + offsetY); + //this.loadedParticles[position] = particle; + return particle; + } + + canSink() { + const particleBelow = this.getParticle(0, 1); + return (particleBelow !== null && particleBelow.liquid && this.particle.density > particleBelow.density); + } + + sink() { + const particleBelow = this.getParticle(0, 1); + return this.replaceWith(particleBelow).moveDown().done(); + } + + replaceWith(newParticle) { + this.updates.push([this.x, this.y, newParticle.withUpdateCooldown(1)]); + return this; + } + + move(deltaX, deltaY) { + this.updates.push([this.x + deltaX, this.y + deltaY, this.particle]); + return this; + } + + moveLeft(particles = 1) { + this.move(-particles, 0); + return this; + } + + moveRight(particles = 1) { + this.move(particles, 0); + return this; + } + + moveUp(particles = 1) { + this.move(0, -particles); + return this; + } + + moveDown(particles = 1) { + this.move(0, particles); + return this; + } + + done() { + return this.updates; + } +} + +export default ParticleUpdate; \ No newline at end of file diff --git a/src/liquid.js b/src/liquid.js index 0fec312..40ec28a 100644 --- a/src/liquid.js +++ b/src/liquid.js @@ -1,60 +1,57 @@ import { Particle } from "./particle"; +import ParticleUpdate from "./lib/update"; class Liquid extends Particle { constructor(type) { super(type, false); } - update(x,y,grid) { - const goLeft = () => { - let clone = grid[x][y]; - grid[x][y] = grid[x-1][y]; - grid[x-1][y] = clone; - }; - - const goRight = () => { - let clone = grid[x][y]; - grid[x][y] = grid[x+1][y]; - grid[x+1][y] = clone; - }; - - //If liquid can go down, do it - if (y+1 < grid[0].length) { - //Attempt to sink, if it works, exit the function - if (this.sink(x,y,grid)) return true; - } + update(x,y,chunk) { + const update = new ParticleUpdate(this, x, y, chunk); + + if (update.canSink()) + return update.sink(); + //Check if liquid can go left or right let left = false; let right = false; - if (x-1 >= 0) { - if (grid[x-1][y].liquid) left = true; - }; - if (x+1 < grid.length) { - if (grid[x+1][y].liquid) right = true; - }; + const particleLeft = update.getParticle(-1, 0); + if (particleLeft !== null && particleLeft.liquid && particleLeft.type !== this.type) + left = true; + + const particleRight = update.getParticle(1, 0); + if (particleRight !== null && particleRight.liquid && particleRight.type !== this.type) + right = true; //If liquid can go either way, choose a random direction if (left && right) { - if (Math.random() > 0.5) { - goLeft(); - return true; - } - - goRight(); - return true; + if (Math.random() > 0.5) + return update + .replaceWith(particleLeft) + .moveLeft() + .done(); + else + return update + .replaceWith(particleRight) + .moveRight() + .done(); } //If liquid can only go one way, choose that way if (left) { - goLeft(); - return true; + return update + .replaceWith(particleLeft) + .moveLeft() + .done(); } else if (right) { - goRight(); - return true; + return update + .replaceWith(particleRight) + .moveRight() + .done(); } - return false; + return []; } } diff --git a/src/particle.js b/src/particle.js index e8b3673..8d58b47 100644 --- a/src/particle.js +++ b/src/particle.js @@ -1,3 +1,5 @@ +import ParticleUpdate from "./lib/update"; + /*Potential particle properties: -colour -type @@ -31,6 +33,11 @@ const particles = { colour: [55,58,54], density: 0.8, liquid: true + }, + "metal": { + colour: [127,127,127], + density: 100, + liquid: false } }; @@ -44,15 +51,31 @@ const getParticleList = () => { class Particle { constructor(type, stationary) { + this.colour = getColour(type); this.type = type; this.colour = particles[this.type]["colour"]; this.density = particles[this.type]["density"]; this.liquid = particles[this.type]["liquid"]; this.static = stationary; + this.updateCooldown = 0; } - update() { - return false; + withUpdateCooldown(cooldown) { + this.updateCooldown = cooldown; + return this; + } + + process(x, y, chunk) { + if (this.updateCooldown > 0) { + this.updateCooldown--; + return ParticleUpdate.NullUpdate; + } + + return this.update(x, y, chunk); + } + + update(x, y, chunk) { + return []; } sink(x,y,grid) { diff --git a/src/particles/metal.js b/src/particles/metal.js new file mode 100644 index 0000000..8e64665 --- /dev/null +++ b/src/particles/metal.js @@ -0,0 +1,10 @@ +import { Particle } from "../particle"; + +class Metal extends Particle { + constructor() { + const type = "metal"; + super(type, true); + } +} + +export default Metal; \ No newline at end of file diff --git a/src/particles/oil.js b/src/particles/oil.js index 49be153..4cdf839 100644 --- a/src/particles/oil.js +++ b/src/particles/oil.js @@ -3,11 +3,7 @@ import Liquid from "../liquid"; class Oil extends Liquid { constructor() { const type = "oil"; - super(type); - } - - update(x,y,grid) { - super.update(x,y,grid); + super(type, false); } } diff --git a/src/particles/sand.js b/src/particles/sand.js index 9e43a2a..8d7e354 100644 --- a/src/particles/sand.js +++ b/src/particles/sand.js @@ -1,5 +1,8 @@ -import { Particle } from "../particle"; +"use strict"; + import Air from "./air"; +import { Particle } from "../particle"; +import ParticleUpdate from "../lib/update"; class Sand extends Particle { constructor() { @@ -7,63 +10,51 @@ class Sand extends Particle { super(type, false); } - update(x,y,grid) { - const goDown = () => { - grid[x][y+1] = grid[x][y]; - grid[x][y] = new Air(); - }; - - const goLeft = () => { - grid[x-1][y+1] = grid[x][y]; - grid[x][y] = new Air(); - }; - - const goRight = () => { - grid[x+1][y+1] = grid[x][y]; - grid[x][y] = new Air(); - }; - - //Check if sand is on the ground, if not attempt to sink sand - if (y+1 > grid[0].length-1) { + update(x,y,chunk) { + const update = new ParticleUpdate(this, x, y, chunk); + //Check if sand is on the ground + const particleBelow = update.getParticle(0, 1); + if (particleBelow === null) { this.static = true; - return false; - } else { - //Attempt to sink, if it works, exit the function - if (this.sink(x,y,grid)) return true; + return []; } + if (update.canSink()) + return update.sink(); + //If sand can't fall down check if it can fall left or right let left = false; let right = false; - if (x-1 >= 0) { - if (grid[x-1][y+1].type == "air") left = true; - }; - if (x+1 < grid.length) { - if (grid[x+1][y+1].type == "air") right = true; - }; + const particleLeft = update.getParticle(-1, 1); + if (particleLeft !== null && particleLeft.type === "air") + left = true; + + const particleRight = update.getParticle(1, 1); + if (particleRight !== null && particleRight.type === "air") + right = true; //If sand can fall either way, choose a random direction if (left && right) { - if (Math.random() < 0.5) { - goLeft(); - return true; - } - - goRight(); - return true; + return update + .replaceWith(new Air()) + .move(Math.random() > 0.5 ? -1 : 1, 1) + .done(); } //If sand can only go one way, choose that way - if (left) { - goLeft(); - return true; - } else if (right) { - goRight(); - return true; - } - - return false; + if (left) + return update + .replaceWith(new Air()) + .move(-1, 1) + .done(); + else if (right) + return update + .replaceWith(new Air()) + .move(1, 1) + .done(); + + return []; } } diff --git a/src/particles/water.js b/src/particles/water.js index e7a9ec0..6d452c4 100644 --- a/src/particles/water.js +++ b/src/particles/water.js @@ -3,11 +3,7 @@ import Liquid from "../liquid"; class Water extends Liquid { constructor() { const type = "water"; - super(type); - } - - update(x,y,grid) { - super.update(x,y,grid); + super(type, false); } } diff --git a/src/screen.js b/src/screen.js index a9502e3..149311a 100644 --- a/src/screen.js +++ b/src/screen.js @@ -1,77 +1,69 @@ +import ChunkManager from "./chunk"; import { getColour, getParticleList } from "./particle"; import Air from "./particles/air" +import Metal from "./particles/metal"; import Oil from "./particles/oil"; import Sand from "./particles/sand"; import Water from "./particles/water"; class Screen { - constructor(windowWidth, windowHeight, particleSize, sketchObj) { + constructor(windowWidth, windowHeight, particleSize, chunkSize, sketchObj) { //Functions to initiate grid const calculateDimensions = (windowWidth, windowHeight, particleSize) => { - let width = Math.floor(windowWidth / particleSize) * particleSize; - let height = Math.floor(windowHeight / particleSize) * particleSize; - return [width,height]; + // Misleading name: this is actually the how many pixels wide a chunk is + let pixelsPerChunk = (particleSize * chunkSize) + let widthInChunks = Math.floor(windowWidth / pixelsPerChunk); + let heightInChunks = Math.floor(windowHeight / pixelsPerChunk); + // return [ 1024, 1024, 16, 16, 1, 1 ]; + return [ + widthInChunks * pixelsPerChunk, // screen width px + heightInChunks * pixelsPerChunk, // screen height px + widthInChunks * chunkSize, // screen width in particles + heightInChunks * chunkSize, // screen height in particles + widthInChunks, // screen width in chunks + heightInChunks // screen height in chunks + ]; } - const generateGrid = (particleSize) => { - let cols = this.width / particleSize; - let colHeight = this.height / particleSize; - - let grid = new Array(cols); - - for (var x=0;x