diff --git a/.cspell/project-words.txt b/.cspell/project-words.txt index 05e2ae64..fe8922ee 100644 --- a/.cspell/project-words.txt +++ b/.cspell/project-words.txt @@ -6,8 +6,10 @@ autofetch backquote Batchable Baumgarte +Beziers blits cachable +Catmull commitlint dbaeumer deadzone @@ -25,6 +27,7 @@ gamepadconnected Gamepads GLSL greyscale +heightmap Heliagon highp hitscan @@ -78,11 +81,14 @@ texels thresholded thresholding thumbstick +tileable +tintable unblurred undersamples unmarks unnegated unrotated +untextured updatables upsamples uvec diff --git a/documentation-site/docs/docs/physics/index.md b/documentation-site/docs/docs/physics/index.md index 1c41ef94..8543aff7 100644 --- a/documentation-site/docs/docs/physics/index.md +++ b/documentation-site/docs/docs/physics/index.md @@ -17,6 +17,8 @@ Core concepts: - [`CircleShape`](/Forge/docs/api/classes/CircleShape) and [`PolygonShape`](/Forge/docs/api/classes/PolygonShape): the convex shapes a `RigidBody` can have. +- [`TerrainShape`](/Forge/docs/api/classes/TerrainShape): a static heightmap + ground shape for non-convex 2D terrain. - [`raycast`](/Forge/docs/api/functions/raycast): casts a ray against a set of bodies. - [`PrismaticJoint`](/Forge/docs/api/classes/PrismaticJoint): a slider @@ -41,6 +43,8 @@ Guides in this section: along a single axis. - [Revolute Joints (Hinges)](./revolute-joints.md): pinning bodies together at a point while leaving rotation free. +- [Terrain](./terrain.md): building non-convex 2D ground out of a + heightmap. ## Quick Start diff --git a/documentation-site/docs/docs/physics/terrain.md b/documentation-site/docs/docs/physics/terrain.md new file mode 100644 index 00000000..9e1e02f8 --- /dev/null +++ b/documentation-site/docs/docs/physics/terrain.md @@ -0,0 +1,184 @@ +--- +sidebar_position: 6 +--- + +# Terrain + +[`TerrainShape`](/Forge/docs/api/classes/TerrainShape) is a collision shape +for static 2D ground: a heightmap made of surface points, ordered left to +right, closed off into a solid slab by a flat bottom edge. Use it instead of +a `PolygonShape` when your ground isn't a single convex box - rolling hills, +a canyon profile, or any left-to-right surface with more than one slope. + +```ts +import { Vector2 } from '@forge-game-engine/forge/math'; +import { RigidBody, TerrainShape } from '@forge-game-engine/forge/physics'; + +const groundBody = new RigidBody({ + shape: new TerrainShape( + [ + new Vector2(-400, 40), + new Vector2(-200, -20), + new Vector2(0, 0), + new Vector2(200, -60), + new Vector2(400, 20), + ], + 200, // depth: how far the solid slab extends below the lowest point + ), + isStatic: true, +}); +``` + +## Authoring points + +`points` must have at least 2 entries and be ordered by strictly increasing +`x` - `TerrainShape` throws otherwise. Unlike `PolygonShape`, it does **not** +re-center vertices around their centroid: `points` are used exactly as +authored, in the shape's own local space, so the easiest way to work with +terrain is to author points directly in world coordinates and leave the +owning body's `position` at `Vector2.zero`. + +`depth` sets how far the solid slab extends below the lowest of `points`, +closing the heightmap into a shape with well-defined area/collision volume. +It only needs to be deep enough that nothing can tunnel through the bottom; +a few hundred units is typically more than enough headroom. + +:::caution +`TerrainShape` is intended for **static** bodies only (`isStatic: true`). A +heightmap has no natural mass distribution to simulate as a moving object - +`getArea`/`getMomentOfInertia` are implemented for interface-completeness, +but nothing in the engine exercises a dynamic terrain body. +::: + +## How collision works + +Internally, `TerrainShape` triangulates the heightmap into one convex quad +per consecutive pair of points (`segments`), each spanning from the two +surface points down to the shared flat bottom edge. `PhysicsWorld` dispatches +circle/polygon-vs-terrain collisions by running the existing +circle-vs-polygon and polygon-vs-polygon narrow phase against whichever +segments overlap the other body's local x-range, then keeping the deepest +resulting contact. This means terrain collision reuses the same, already +battle-tested SAT code paths as `CircleShape`/`PolygonShape` - there's no +separate "terrain physics" to reason about, and any body that already +collides correctly with a `PolygonShape` floor collides correctly with a +`TerrainShape` one too. + +Broad-phase culling (`RigidBody.aabb`) still uses `getBoundingRadius()`, +which for a long terrain strip produces a large, roughly-square bounding box +around the whole shape (the same simplification `PolygonShape` makes for any +elongated shape). This doesn't affect correctness, only how many pairs reach +the narrow phase - for very large worlds, prefer several shorter +`TerrainShape` bodies over one shape spanning the whole level. + +## Rendering + +The engine's sprite renderer draws rotated/scaled quads and can't render a +heightmap's silhouette, so `@forge-game-engine/forge/rendering` ships a +small terrain-specific pipeline alongside `TerrainShape`: a smooth curve +builder, a mesh builder, and a draw system. All three are demonstrated end +to end in the [Rolling Ball demo](/Forge/demos/rolling-ball). + +### Building a smooth curve + +[`buildTerrainCurve`](/Forge/docs/api/functions/buildTerrainCurve) turns a +handful of sparse control points into a long, natural-looking silhouette: a +Catmull-Rom spline through the control points (converted to an equivalent +sequence of cubic Beziers under the hood), densely sampled into a polyline. + +```ts +import { Vector2 } from '@forge-game-engine/forge/math'; +import { buildTerrainCurve } from '@forge-game-engine/forge/rendering'; + +const curvePoints = buildTerrainCurve( + [ + new Vector2(-400, 40), + new Vector2(-200, -20), + new Vector2(0, 0), + new Vector2(200, -60), + new Vector2(400, 20), + ], + 20, // samplesPerSegment: how many points to sample between each pair of control points +); +``` + +Feed the same `curvePoints` into both `TerrainShape` (mapping each point's +`.position` into the `Vector2[]` it expects) and the render mesh below, so +what's drawn always matches exactly what the body collides with: + +```ts +const points = curvePoints.map((curvePoint) => curvePoint.position); +const terrainShape = new TerrainShape(points, 200); +``` + +[`heightAtLocalX`](/Forge/docs/api/functions/heightAtLocalX) looks up a +curve's interpolated surface height at a given local-space `x` - handy for +placing anything (a player's spawn point, a patrolling enemy) exactly on +the terrain's surface. + +### Building the mesh + +[`createTerrainMesh`](/Forge/docs/api/functions/createTerrainMesh) +triangulates `curvePoints` into a single mesh and textures it with two +independently-tileable layers: a "border" texture near the surface blending +into a "fill" texture below it, over a configurable depth and blend width, +each with its own tile size and tint. + +```ts +import { Color, createTerrainMesh } from '@forge-game-engine/forge/rendering'; + +const borderImage = await renderContext.imageCache.getOrLoad('grass.png'); +const fillImage = await renderContext.imageCache.getOrLoad('dirt.png'); + +const mesh = createTerrainMesh(renderContext, { + curvePoints, + depth: 200, // must match the TerrainShape's depth + position: Vector2.zero, // must match the RigidBody's position + angle: 0, // must match the RigidBody's angle + border: { + image: borderImage, + tileSize: new Vector2(160, 70), + tint: Color.white, + }, + fill: { + image: fillImage, + tileSize: new Vector2(90, 90), + tint: Color.white, + }, + borderWidth: 40, + borderBlend: 14, // optional, defaults to 12 +}); +``` + +`createTerrainMesh` loads each layer's texture with `REPEAT` wrapping on +both axes (rather than the `CLAMP_TO_EDGE` `createTextureFromImage` uses by +default for ordinary sprite frames, which must never bleed into a +neighboring frame in the same atlas) - pass `tile: true` to +`createTextureFromImage` directly if you ever need a tiled texture outside +this pipeline. + +### Drawing the mesh + +Since the mesh has an arbitrary vertex count - not the fixed +six-vertices-per-quad the sprite pipeline batches - +[`createTerrainRenderEcsSystem`](/Forge/docs/api/functions/createTerrainRenderEcsSystem) +draws it with its own direct, non-instanced `gl.drawArrays` call instead of +going through `createRenderEcsSystem`. + +```ts +import { createTerrainRenderEcsSystem } from '@forge-game-engine/forge/rendering'; + +world.addSystem(createTerrainRenderEcsSystem(renderContext, mesh)); +world.addSystem(createRenderEcsSystem(renderContext)); +``` + +:::caution[Coexisting with the sprite pipeline] +Register the terrain system _before_ `createRenderEcsSystem` so sprites +draw on top of the terrain mesh underneath them. But `createRenderEcsSystem` +clears its destination the first time it's used each frame (see +`RenderContext.clearStrategy`), which would wipe out the terrain mesh drawn +just before it. Set `renderContext.clearStrategy = CLEAR_STRATEGY.none` so +neither system's automatic clear fires - `createTerrainRenderEcsSystem` +does the frame's one real clear itself, first. See the Rolling Ball demo's +`create-game.ts` for the complete setup. +::: diff --git a/documentation-site/docusaurus.config.ts b/documentation-site/docusaurus.config.ts index 7513973d..124bc8c6 100644 --- a/documentation-site/docusaurus.config.ts +++ b/documentation-site/docusaurus.config.ts @@ -119,6 +119,10 @@ const config: Config = { to: 'demos/physics', label: 'Physics', }, + { + to: 'demos/rolling-ball', + label: 'Rolling Ball', + }, { to: 'demos/prismatic-joint', label: 'Prismatic Joint (Slider)', diff --git a/documentation-site/src/pages/demos/rolling-ball/_camera-follow.system.ts b/documentation-site/src/pages/demos/rolling-ball/_camera-follow.system.ts new file mode 100644 index 00000000..163b16f3 --- /dev/null +++ b/documentation-site/src/pages/demos/rolling-ball/_camera-follow.system.ts @@ -0,0 +1,44 @@ +import { EcsSystem } from '@forge-game-engine/forge/ecs'; +import { + PositionEcsComponent, + Time, + positionId, +} from '@forge-game-engine/forge/common'; +import { + CameraEcsComponent, + cameraId, +} from '@forge-game-engine/forge/rendering'; + +/** How quickly the camera closes the gap to the ball, per second. */ +const followSpeed = 4; + +/** + * Creates an ECS system that smoothly pans the camera towards + * `targetPosition` every tick, using exponential smoothing so it eases + * towards the ball rather than snapping to it or lagging at a fixed offset. + * + * Writes `position.world` directly (rather than `position.local`, which + * `createCameraEcsSystem`'s own pan/zoom input handling uses) since this + * demo doesn't register `createTransformEcsSystem` to compute `world` from + * `local` - the same convention every entity in this demo (and the other + * physics demos) follows. + * @param targetPosition - The position component to follow, typically the ball's. + * @param time - The time instance used to scale the follow speed by delta time. + */ +export const createCameraFollowEcsSystem = ( + targetPosition: PositionEcsComponent, + time: Time, +): EcsSystem<[CameraEcsComponent, PositionEcsComponent]> => ({ + query: [cameraId, positionId], + run: (result) => { + const [, cameraPosition] = result.components; + const t = 1 - Math.exp(-followSpeed * time.deltaTimeInSeconds); + + cameraPosition.world.x += + (targetPosition.world.x - cameraPosition.world.x) * t; + cameraPosition.world.y += + (targetPosition.world.y - cameraPosition.world.y) * t; + cameraPosition.local.x = cameraPosition.world.x; + cameraPosition.local.y = cameraPosition.world.y; + }, +}); diff --git a/documentation-site/src/pages/demos/rolling-ball/_create-game.ts b/documentation-site/src/pages/demos/rolling-ball/_create-game.ts new file mode 100644 index 00000000..cec75ee0 --- /dev/null +++ b/documentation-site/src/pages/demos/rolling-ball/_create-game.ts @@ -0,0 +1,123 @@ +import { + CLEAR_STRATEGY, + Color, + createCamera, + createRenderEcsSystem, + createTerrainRenderEcsSystem, +} from '@forge-game-engine/forge/rendering'; +import { createGame, Game } from '@forge-game-engine/forge/utilities'; +import { + createAngularVelocityMotorEcsSystem, + createPhysicsSyncEcsSystem, + PhysicsWorld, +} from '@forge-game-engine/forge/physics'; +import { + PositionEcsComponent, + positionId, +} from '@forge-game-engine/forge/common'; +import { Vector2 } from '@forge-game-engine/forge/math'; +import { getAssetUrl } from '@site/src/utils/get-asset-url'; +import { createInputs } from './_create-inputs'; +import { createTerrain } from './_create-terrain'; +import { createPlayer } from './_create-player'; +import { createRollEcsSystem } from './_roll.system'; +import { createJumpEcsSystem } from './_jump.system'; +import { createCameraFollowEcsSystem } from './_camera-follow.system'; + +const renderLayers = { + foreground: 1 << 0, +}; + +const gravity = new Vector2(0, -700); +const terrainWidth = 8000; + +export const createRollingBallGame = async (): Promise => { + const { game, world, renderContext, time } = createGame('demo-game'); + + // The terrain render system draws directly, outside the sprite pipeline + // (see createTerrainRenderEcsSystem), and owns the frame's one real + // clear; without `none` here, createRenderEcsSystem's own clear would + // wipe the terrain right before drawing the sprites on top of it. + renderContext.clearStrategy = CLEAR_STRATEGY.none; + + const cameraEntity = createCamera(world, { + isStatic: true, + cullingMask: renderLayers.foreground, + }); + + const physicsWorld = new PhysicsWorld({ gravity }); + + const { rollInput, jumpInput } = createInputs(world, time); + + const terrain = await createTerrain(world, renderContext, { + totalWidth: terrainWidth, + border: { + textureUrl: getAssetUrl( + 'img/kenney_pattern-pack/PNG/Default/pattern_19.png', + ), + tileSize: new Vector2(160, 150), + tint: new Color(1, 1, 1, 1), + }, + fill: { + textureUrl: getAssetUrl( + 'img/kenney_pattern-pack/PNG/Default/pattern_37.png', + ), + tileSize: new Vector2(30, 30), + tint: new Color(0.4, 0.29, 0.18, 1), + }, + borderWidth: 30, + borderBlend: 10, + }); + + const spawnPosition = new Vector2( + terrain.spawnX, + terrain.worldSurfaceYAt(terrain.spawnX) + 60, + ); + + const player = await createPlayer( + world, + renderContext, + renderLayers.foreground, + spawnPosition, + ); + + const playerPosition = world.getComponent( + player.entity, + positionId, + )!; + + // Start the camera already centered on the ball, rather than easing in + // from the world origin on the first frame. + const cameraPosition = world.getComponent( + cameraEntity, + positionId, + )!; + + cameraPosition.world = spawnPosition.clone(); + cameraPosition.local = spawnPosition.clone(); + + // Roll input must be applied before the motor system, which must in turn + // run before createPhysicsSyncEcsSystem, so this tick's input reaches + // this tick's physics step - see the Applying Forces guide's registration + // order caution. The jump/respawn and camera-follow systems read the + // physics step's results, so they run after it instead. The terrain + // render system must run before createRenderEcsSystem so the ball's + // sprite draws on top of the terrain mesh, not underneath it. + world.addSystem(createRollEcsSystem(rollInput)); + world.addSystem(createAngularVelocityMotorEcsSystem(time)); + world.addSystem(createPhysicsSyncEcsSystem(physicsWorld, time)); + world.addSystem( + createJumpEcsSystem( + physicsWorld, + player.body, + terrain.body, + jumpInput, + spawnPosition, + ), + ); + world.addSystem(createCameraFollowEcsSystem(playerPosition, time)); + world.addSystem(createTerrainRenderEcsSystem(renderContext, terrain.mesh)); + world.addSystem(createRenderEcsSystem(renderContext)); + + return game; +}; diff --git a/documentation-site/src/pages/demos/rolling-ball/_create-inputs.ts b/documentation-site/src/pages/demos/rolling-ball/_create-inputs.ts new file mode 100644 index 00000000..fdd512b1 --- /dev/null +++ b/documentation-site/src/pages/demos/rolling-ball/_create-inputs.ts @@ -0,0 +1,52 @@ +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { Time } from '@forge-game-engine/forge/common'; +import { + actionResetTypes, + Axis1dAction, + buttonMoments, + KeyboardAxis1dBinding, + KeyboardInputSource, + KeyboardTriggerBinding, + keyCodes, + registerInputs, + TriggerAction, +} from '@forge-game-engine/forge/input'; + +export function createInputs( + world: EcsWorld, + time: Time, +): { + rollInput: Axis1dAction; + jumpInput: TriggerAction; +} { + // `noReset`, since the axis is driven by discrete keydown/keyup edges + // (see KeyboardAxis1dBinding), not re-read every tick - the default + // `zero` reset would zero it out again the instant after each keydown. + const rollInput = new Axis1dAction('roll', null, actionResetTypes.noReset); + const jumpInput = new TriggerAction('jump'); + + const inputManager = registerInputs(world, time, { + axis1dActions: [rollInput], + triggerActions: [jumpInput], + }); + + const keyboardInputSource = new KeyboardInputSource(inputManager); + + keyboardInputSource.axis1dBindings.add( + new KeyboardAxis1dBinding(rollInput, keyCodes.d, keyCodes.a), + ); + + keyboardInputSource.axis1dBindings.add( + new KeyboardAxis1dBinding( + rollInput, + keyCodes.arrowRight, + keyCodes.arrowLeft, + ), + ); + + keyboardInputSource.triggerBindings.add( + new KeyboardTriggerBinding(jumpInput, keyCodes.space, buttonMoments.down), + ); + + return { rollInput, jumpInput }; +} diff --git a/documentation-site/src/pages/demos/rolling-ball/_create-player.ts b/documentation-site/src/pages/demos/rolling-ball/_create-player.ts new file mode 100644 index 00000000..6e56a5fd --- /dev/null +++ b/documentation-site/src/pages/demos/rolling-ball/_create-player.ts @@ -0,0 +1,87 @@ +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { positionId, rotationId } from '@forge-game-engine/forge/common'; +import { Vector2 } from '@forge-game-engine/forge/math'; +import { + addAngularVelocityMotorComponent, + CircleShape, + PhysicsBodyId, + RigidBody, +} from '@forge-game-engine/forge/physics'; +import { + createImageSprite, + RenderContext, + spriteId, +} from '@forge-game-engine/forge/rendering'; +import { getAssetUrl } from '@site/src/utils/get-asset-url'; + +export const ballRadius = 24; + +/** + * The maximum torque the ball's `AngularVelocityMotorEcsComponent` may + * spend reaching its roll input's target angular velocity, tuned relative + * to the ball's own moment of inertia so it ramps up to speed over a few + * tenths of a second rather than snapping instantly or crawling. + */ +export const ballMaxTorque = 30_000_000; + +export interface Player { + /** The entity carrying the ball's sprite, position and physics body. */ + entity: number; + + /** The ball's `RigidBody`, for grounded checks and jump impulses. */ + body: RigidBody; +} + +/** + * Creates the player-controlled ball: a dynamic `CircleShape` body with an + * `AngularVelocityMotorEcsComponent` (driven by roll input, see + * `createRollEcsSystem`) that lets friction against the terrain turn spin + * into rolling motion, exactly like a real ball. + * @param world - The ECS world to add the ball entity to. + * @param renderContext - The render context used to load the ball sprite. + * @param renderLayer - The render layer the ball should be drawn on. + * @param spawnPosition - The world-space position to spawn the ball at. + */ +export async function createPlayer( + world: EcsWorld, + renderContext: RenderContext, + renderLayer: number, + spawnPosition: Vector2, +): Promise { + const ballImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/physics/ball_blue_large.png'), + ); + const ballSprite = createImageSprite(ballImage, renderContext, renderLayer); + + const ballBody = new RigidBody({ + shape: new CircleShape(ballRadius), + position: spawnPosition.clone(), + density: 1.2, + friction: 0.9, + restitution: 0.15, + }); + + const entity = world.createEntity(); + + world.addComponent(entity, positionId, { + world: spawnPosition.clone(), + local: spawnPosition.clone(), + }); + + world.addComponent(entity, rotationId, { local: 0, world: 0 }); + + world.addComponent(entity, spriteId, { + ...ballSprite, + width: ballRadius * 2, + height: ballRadius * 2, + }); + + world.addComponent(entity, PhysicsBodyId, { physicsBody: ballBody }); + + addAngularVelocityMotorComponent(world, entity, { + targetVelocity: 0, + maxTorque: ballMaxTorque, + }); + + return { entity, body: ballBody }; +} diff --git a/documentation-site/src/pages/demos/rolling-ball/_create-terrain.ts b/documentation-site/src/pages/demos/rolling-ball/_create-terrain.ts new file mode 100644 index 00000000..650a11a7 --- /dev/null +++ b/documentation-site/src/pages/demos/rolling-ball/_create-terrain.ts @@ -0,0 +1,228 @@ +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { positionId, rotationId } from '@forge-game-engine/forge/common'; +import { clamp, Random, Vector2 } from '@forge-game-engine/forge/math'; +import { + PhysicsBodyId, + RigidBody, + TerrainShape, +} from '@forge-game-engine/forge/physics'; +import { + buildTerrainCurve, + Color, + createTerrainMesh, + heightAtLocalX, + RenderContext, + TerrainMesh, +} from '@forge-game-engine/forge/rendering'; + +// How far apart (in local x) the sparse control points the terrain curve is +// built from are. Small enough that a smooth curve through them still reads +// as "rolling hills" rather than a handful of disconnected bumps. +const anchorSpacing = 400; + +// How many curve points to sample per anchor-to-anchor segment. Both the +// TerrainShape collision points and the render mesh use this same dense +// sampling, so what the ball touches always matches what's drawn. +const samplesPerSegment = 20; + +const terrainDepth = 500; +const maxStepHeight = 50; +const minHeight = -180; +const maxHeight = 180; + +// The number of anchors, centered on x = 0, held flat at height 0 so the +// ball spawns on a predictable platform instead of partway up a slope. +const flatAnchorRadius = 1; + +/** + * Texture and tiling options for one of the terrain's two texture layers + * (see {@link CreateTerrainOptions}). + */ +export interface TerrainLayerOptions { + /** URL of the image to tile across this layer. */ + textureUrl: string; + + /** + * The world-space size of one tile of the texture: x tiles along the + * curve's arc length, y tiles into the ground. Smaller values repeat the + * texture more often over the same span of terrain. + */ + tileSize: Vector2; + + /** Tint multiplied against the sampled texture. */ + tint: Color; +} + +export interface CreateTerrainOptions { + /** The total width of the terrain, in world units. */ + totalWidth: number; + + /** The texture tiled across the terrain's surface, from the surface down to `borderWidth`. */ + border: TerrainLayerOptions; + + /** The texture tiled across the terrain's interior, below `borderWidth`. */ + fill: TerrainLayerOptions; + + /** + * How deep (in world units) the border layer extends below the surface + * before blending into the fill layer. + */ + borderWidth: number; + + /** + * How wide (in world units) the blend between the border and fill layers + * is, centered on `borderWidth`. + */ + borderBlend: number; +} + +export interface RollingBallTerrain { + /** The terrain's static `RigidBody`, for reference (e.g. grounded checks). */ + body: RigidBody; + + /** + * The world-space x coordinate of the flat platform the ball spawns on, + * near the start of the terrain. + */ + spawnX: number; + + /** + * Returns the world-space y coordinate of the terrain's surface at a + * given world-space x. Used to place the ball (and anything else) exactly + * on the ground, accounting for the terrain body's rotation (see below). + */ + worldSurfaceYAt: (worldX: number) => number; + + /** The terrain's render mesh, for `createTerrainRenderEcsSystem`. */ + mesh: TerrainMesh; +} + +function buildControlPoints(totalWidth: number): Vector2[] { + const halfWidth = totalWidth / 2; + const anchorCount = Math.round(totalWidth / anchorSpacing) + 1; + const random = new Random('rolling-ball-terrain'); + + const controlPoints: Vector2[] = []; + let previousHeight = 0; + + for (let i = 0; i < anchorCount; i++) { + const x = + i === anchorCount - 1 ? halfWidth : -halfWidth + i * anchorSpacing; + const isFlatZone = Math.abs(x) <= anchorSpacing * flatAnchorRadius + 1; + + let height: number; + + if (isFlatZone) { + height = 0; + } else { + const step = random.randomFloat(-maxStepHeight, maxStepHeight); + + height = clamp(previousHeight + step, minHeight, maxHeight); + } + + previousHeight = height; + controlPoints.push(new Vector2(x, height)); + } + + return controlPoints; +} + +/** + * Creates a long, varied-height `TerrainShape` ground body - a smooth curve + * through sparse, randomly-placed control points (see + * `buildTerrainCurve`), rather than a jagged polyline - plus a single + * triangulated mesh to render it (see `createTerrainMesh`), textured with a + * tileable border layer near the surface blending into a tileable fill + * layer below (see `CreateTerrainOptions`). Pair with + * `createTerrainRenderEcsSystem` to draw the returned `mesh`. + * @param world - The ECS world to add the terrain entity to. + * @param renderContext - The render context used to load the terrain's textures and build its mesh. + * @param options - Sizing and texturing options for the terrain. + */ +export async function createTerrain( + world: EcsWorld, + renderContext: RenderContext, + options: CreateTerrainOptions, +): Promise { + const { totalWidth, border, fill, borderWidth, borderBlend } = options; + + const controlPoints = buildControlPoints(totalWidth); + const curvePoints = buildTerrainCurve(controlPoints, samplesPerSegment); + const points = curvePoints.map((curvePoint) => curvePoint.position); + + const terrainShape = new TerrainShape(points, terrainDepth); + + // Rotated 180 degrees, same as the Physics demo's terrain: `TerrainShape` + // always extends its solid slab `depth` units in the +y direction from + // its surface points (in its own local space), but this demo's gravity + // pulls bodies toward -y, so the body is flipped to face the right way. + const angle = Math.PI; + const position = Vector2.zero; + + const terrainBody = new RigidBody({ + shape: terrainShape, + position, + angle, + isStatic: true, + friction: 0.9, + }); + + const terrainEntity = world.createEntity(); + + world.addComponent(terrainEntity, positionId, { + world: position.clone(), + local: position.clone(), + }); + + // `RotationEcsComponent.world` is negated relative to `RigidBody.angle` + // (see the "Coordinate spaces" note in the Bodies and Shapes guide), the + // same convention `createPhysicsSyncEcsSystem` uses for every other + // static body. + world.addComponent(terrainEntity, rotationId, { + local: -angle, + world: -angle, + }); + + world.addComponent(terrainEntity, PhysicsBodyId, { + physicsBody: terrainBody, + }); + + const [borderImage, fillImage] = await Promise.all([ + renderContext.imageCache.getOrLoad(border.textureUrl), + renderContext.imageCache.getOrLoad(fill.textureUrl), + ]); + + const mesh = createTerrainMesh(renderContext, { + curvePoints, + depth: terrainDepth, + position, + angle, + border: { + image: borderImage, + tileSize: border.tileSize, + tint: border.tint, + }, + fill: { + image: fillImage, + tileSize: fill.tileSize, + tint: fill.tint, + }, + borderWidth, + borderBlend, + }); + + const spawnX = position.x; + + const worldSurfaceYAt = (worldX: number): number => { + const localX = position.x - worldX; + + return position.y - heightAtLocalX(curvePoints, localX); + }; + + return { + body: terrainBody, + spawnX, + worldSurfaceYAt, + mesh, + }; +} diff --git a/documentation-site/src/pages/demos/rolling-ball/_jump.system.ts b/documentation-site/src/pages/demos/rolling-ball/_jump.system.ts new file mode 100644 index 00000000..d4ad32a5 --- /dev/null +++ b/documentation-site/src/pages/demos/rolling-ball/_jump.system.ts @@ -0,0 +1,102 @@ +import { EcsSystem } from '@forge-game-engine/forge/ecs'; +import { Vector2 } from '@forge-game-engine/forge/math'; +import { TriggerAction } from '@forge-game-engine/forge/input'; +import { + BodyCollisionPair, + PhysicsBodyEcsComponent, + PhysicsBodyId, + PhysicsWorld, + RigidBody, +} from '@forge-game-engine/forge/physics'; + +/** The upward impulse applied on jump. */ +const jumpImpulse = 500_000; + +/** + * How far below the spawn point (in world y) the ball has to fall - off the + * end of the terrain, or through it after some unexpected physics glitch - + * before it's treated as "lost" and reset back to the start, rather than + * falling forever off-camera. + */ +const respawnFallDistance = 2000; + +function involvesBoth( + pair: BodyCollisionPair, + bodyA: RigidBody, + bodyB: RigidBody, +): boolean { + return ( + (pair.bodyA === bodyA && pair.bodyB === bodyB) || + (pair.bodyA === bodyB && pair.bodyB === bodyA) + ); +} + +/** + * Creates an ECS system that tracks whether the ball is currently touching + * the terrain (via `physicsWorld.collisionStarts`/`collisionEnds`, which + * `createPhysicsSyncEcsSystem` populates every tick), applies an upward + * impulse when `jumpInput` triggers while grounded, and resets the ball back + * to `spawnPosition` if it ever falls `respawnFallDistance` below it (for + * example off the end of the terrain). All the work happens once per tick in + * `beforeQuery`, rather than per matched entity, the same way + * `createPhysicsSyncEcsSystem` steps `physicsWorld` once in its own + * `beforeQuery`. + * + * Must run after `createPhysicsSyncEcsSystem`, so this tick's collision + * events are available before this system checks them. + * @param physicsWorld - The physics world the ball and terrain are simulated in. + * @param playerBody - The ball's `RigidBody`. + * @param terrainBody - The terrain's `RigidBody`. + * @param jumpInput - The jump trigger action. + * @param spawnPosition - The world-space position to reset the ball to if it falls too far. + */ +export const createJumpEcsSystem = ( + physicsWorld: PhysicsWorld, + playerBody: RigidBody, + terrainBody: RigidBody, + jumpInput: TriggerAction, + spawnPosition: Vector2, +): EcsSystem<[PhysicsBodyEcsComponent]> => { + let isGrounded = false; + + return { + query: [PhysicsBodyId], + beforeQuery: () => { + if ( + physicsWorld.collisionStarts.some((pair) => + involvesBoth(pair, playerBody, terrainBody), + ) + ) { + isGrounded = true; + } + + if ( + physicsWorld.collisionEnds.some((pair) => + involvesBoth(pair, playerBody, terrainBody), + ) + ) { + isGrounded = false; + } + + if (jumpInput.isTriggered && isGrounded) { + playerBody.applyImpulse(new Vector2(0, jumpImpulse), Vector2.zero); + isGrounded = false; + } + + // Gravity pulls toward -y in this demo (see `_create-game.ts`), so + // "fallen too far" means the ball's y has dropped well below spawn. + if (playerBody.position.y < spawnPosition.y - respawnFallDistance) { + playerBody.position = spawnPosition.clone(); + playerBody.velocity = Vector2.zero; + playerBody.angularVelocity = 0; + isGrounded = false; + } + + return null; + }, + run: () => { + // All the work happens once per tick in `beforeQuery` above; nothing + // needed per matched entity. + }, + }; +}; diff --git a/documentation-site/src/pages/demos/rolling-ball/_roll.system.ts b/documentation-site/src/pages/demos/rolling-ball/_roll.system.ts new file mode 100644 index 00000000..2b5bc8ca --- /dev/null +++ b/documentation-site/src/pages/demos/rolling-ball/_roll.system.ts @@ -0,0 +1,37 @@ +import { EcsSystem } from '@forge-game-engine/forge/ecs'; +import { Axis1dAction } from '@forge-game-engine/forge/input'; +import { + AngularVelocityMotorEcsComponent, + AngularVelocityMotorId, +} from '@forge-game-engine/forge/physics'; + +/** How fast the ball's motor spins it up to, in rad/s, at full roll input. */ +const maxRollSpeed = 22; + +/** + * Creates an ECS system that sets the ball's + * `AngularVelocityMotorEcsComponent.targetVelocity` from `rollInput` every + * tick. `createAngularVelocityMotorEcsSystem` then spends the motor's + * `maxTorque` budget spinning the ball towards that target, and friction + * against the terrain turns that spin into rolling motion. + * + * Must run before `createAngularVelocityMotorEcsSystem` (and, transitively, + * before `createPhysicsSyncEcsSystem`), so this tick's input is reflected in + * this tick's physics step - see the Applying Forces guide's registration + * order caution. + * @param rollInput - The roll axis action, positive for rightward roll. + */ +export const createRollEcsSystem = ( + rollInput: Axis1dAction, +): EcsSystem<[AngularVelocityMotorEcsComponent]> => ({ + query: [AngularVelocityMotorId], + run: (result) => { + const [motorComponent] = result.components; + + // A positive angular velocity here spins the ball counter-clockwise on + // screen, which rolls it to the left - the opposite of a positive + // `rollInput.value` (the D / right-arrow side of the binding), so the + // sign is flipped to make D/right-arrow roll right. + motorComponent.targetVelocity = -rollInput.value * maxRollSpeed; + }, +}); diff --git a/documentation-site/src/pages/demos/rolling-ball/index.tsx b/documentation-site/src/pages/demos/rolling-ball/index.tsx new file mode 100644 index 00000000..62b644e9 --- /dev/null +++ b/documentation-site/src/pages/demos/rolling-ball/index.tsx @@ -0,0 +1,74 @@ +import React, { JSX } from 'react'; +import { createRollingBallGame } from './_create-game'; +import gameCode from '!!raw-loader!./_create-game'; +import createTerrainCode from '!!raw-loader!./_create-terrain'; +import createPlayerCode from '!!raw-loader!./_create-player'; +import createInputsCode from '!!raw-loader!./_create-inputs'; +import rollSystemCode from '!!raw-loader!./_roll.system'; +import jumpSystemCode from '!!raw-loader!./_jump.system'; +import cameraFollowSystemCode from '!!raw-loader!./_camera-follow.system'; + +import { Demo } from '@site/src/components/Demo'; +import { InteractionInstruction } from '@site/src/components/_InteractionInstruction'; +import { KeyboardKey } from '@site/src/components/_KeyboardKey'; + +export default function RollingBall(): JSX.Element { + return ( + + } + text="Roll left" + /> + } + text="Roll right" + /> + } + text="Jump" + /> + + } + codeFiles={[ + { + name: 'game.ts', + content: gameCode, + }, + { + name: 'create-terrain.ts', + content: createTerrainCode, + }, + { + name: 'create-player.ts', + content: createPlayerCode, + }, + { + name: 'create-inputs.ts', + content: createInputsCode, + }, + { + name: 'roll.system.ts', + content: rollSystemCode, + }, + { + name: 'jump.system.ts', + content: jumpSystemCode, + }, + { + name: 'camera-follow.system.ts', + content: cameraFollowSystemCode, + }, + ]} + /> + ); +} diff --git a/documentation-site/static/img/kenney_pattern-pack/License.txt b/documentation-site/static/img/kenney_pattern-pack/License.txt new file mode 100644 index 00000000..683c4f99 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/License.txt @@ -0,0 +1,22 @@ + + + Pattern Pack (1.1) + + Created/distributed by Kenney (www.kenney.nl) + Creation date: 25-07-2024 + + ------------------------------ + + License: (Creative Commons Zero, CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + This content is free to use in personal, educational and commercial projects. + Support us by crediting Kenney or www.kenney.nl (this is not mandatory) + + ------------------------------ + + Donate: http://support.kenney.nl + Patreon: http://patreon.com/kenney/ + + Follow on Twitter for updates: + http://twitter.com/KenneyNL \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_01.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_01.png new file mode 100644 index 00000000..928b575a Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_01.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_02.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_02.png new file mode 100644 index 00000000..3ef561ba Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_02.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_03.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_03.png new file mode 100644 index 00000000..1f1cf0ea Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_03.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_04.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_04.png new file mode 100644 index 00000000..5da424cd Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_04.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_05.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_05.png new file mode 100644 index 00000000..c1ce4626 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_05.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_06.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_06.png new file mode 100644 index 00000000..b60bf1ed Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_06.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_07.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_07.png new file mode 100644 index 00000000..b6c46558 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_07.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_08.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_08.png new file mode 100644 index 00000000..7f7bd52f Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_08.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_09.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_09.png new file mode 100644 index 00000000..b9deea98 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_09.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_10.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_10.png new file mode 100644 index 00000000..75ea346f Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_10.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_11.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_11.png new file mode 100644 index 00000000..9a3d3959 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_11.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_12.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_12.png new file mode 100644 index 00000000..f21ebfc8 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_12.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_13.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_13.png new file mode 100644 index 00000000..a2ed1db2 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_13.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_14.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_14.png new file mode 100644 index 00000000..640d7fa2 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_14.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_15.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_15.png new file mode 100644 index 00000000..3e3917f3 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_15.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_16.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_16.png new file mode 100644 index 00000000..31d412af Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_16.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_17.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_17.png new file mode 100644 index 00000000..e37eb5a3 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_17.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_18.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_18.png new file mode 100644 index 00000000..08d01d5d Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_18.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_19.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_19.png new file mode 100644 index 00000000..d17ed64a Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_19.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_20.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_20.png new file mode 100644 index 00000000..c1224cdd Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_20.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_21.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_21.png new file mode 100644 index 00000000..bc3ea218 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_21.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_22.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_22.png new file mode 100644 index 00000000..919a70c6 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_22.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_23.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_23.png new file mode 100644 index 00000000..5f247bca Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_23.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_24.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_24.png new file mode 100644 index 00000000..c2d95924 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_24.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_25.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_25.png new file mode 100644 index 00000000..1718e29b Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_25.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_26.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_26.png new file mode 100644 index 00000000..054b95b4 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_26.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_27.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_27.png new file mode 100644 index 00000000..6e039ca4 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_27.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_28.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_28.png new file mode 100644 index 00000000..24ea590b Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_28.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_29.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_29.png new file mode 100644 index 00000000..46e9471c Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_29.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_30.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_30.png new file mode 100644 index 00000000..f8620e2a Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_30.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_31.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_31.png new file mode 100644 index 00000000..7d1366d9 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_31.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_32.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_32.png new file mode 100644 index 00000000..e1b31032 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_32.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_33.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_33.png new file mode 100644 index 00000000..8eee3206 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_33.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_34.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_34.png new file mode 100644 index 00000000..f6141a80 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_34.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_35.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_35.png new file mode 100644 index 00000000..d1a2e40e Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_35.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_36.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_36.png new file mode 100644 index 00000000..fd67bdbb Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_36.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_37.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_37.png new file mode 100644 index 00000000..e31dd3c2 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_37.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_38.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_38.png new file mode 100644 index 00000000..f3b93cda Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_38.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_39.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_39.png new file mode 100644 index 00000000..5efc8f50 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_39.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_40.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_40.png new file mode 100644 index 00000000..75bd20da Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_40.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_41.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_41.png new file mode 100644 index 00000000..25cfa796 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_41.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_42.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_42.png new file mode 100644 index 00000000..e87835b7 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_42.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_43.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_43.png new file mode 100644 index 00000000..1ff02baa Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_43.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_44.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_44.png new file mode 100644 index 00000000..74ebab67 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_44.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_45.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_45.png new file mode 100644 index 00000000..6451ce75 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_45.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_46.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_46.png new file mode 100644 index 00000000..5cbc291e Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_46.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_47.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_47.png new file mode 100644 index 00000000..fb35939a Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_47.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_48.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_48.png new file mode 100644 index 00000000..c5c1d5d9 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_48.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_49.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_49.png new file mode 100644 index 00000000..4dca11c2 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_49.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_50.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_50.png new file mode 100644 index 00000000..85a80321 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_50.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_51.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_51.png new file mode 100644 index 00000000..b23a3f7e Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_51.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_52.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_52.png new file mode 100644 index 00000000..166e0638 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_52.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_53.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_53.png new file mode 100644 index 00000000..f85747b4 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_53.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_54.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_54.png new file mode 100644 index 00000000..d5c4e855 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_54.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_55.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_55.png new file mode 100644 index 00000000..79b4063d Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_55.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_56.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_56.png new file mode 100644 index 00000000..5b36f4e1 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_56.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_57.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_57.png new file mode 100644 index 00000000..93ae5122 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_57.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_58.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_58.png new file mode 100644 index 00000000..d638c41e Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_58.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_59.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_59.png new file mode 100644 index 00000000..45a6ba0f Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_59.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_60.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_60.png new file mode 100644 index 00000000..e518e8ac Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_60.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_61.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_61.png new file mode 100644 index 00000000..f5568296 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_61.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_62.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_62.png new file mode 100644 index 00000000..8859a78b Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_62.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_63.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_63.png new file mode 100644 index 00000000..18426851 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_63.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_64.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_64.png new file mode 100644 index 00000000..42868634 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_64.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_65.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_65.png new file mode 100644 index 00000000..9d59d267 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_65.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_66.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_66.png new file mode 100644 index 00000000..0b08fc52 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_66.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_67.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_67.png new file mode 100644 index 00000000..b224b3c1 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_67.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_68.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_68.png new file mode 100644 index 00000000..eeaaf48e Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_68.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_69.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_69.png new file mode 100644 index 00000000..661699ef Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_69.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_70.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_70.png new file mode 100644 index 00000000..8ce76231 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_70.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_71.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_71.png new file mode 100644 index 00000000..ad1cc3a7 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_71.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_72.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_72.png new file mode 100644 index 00000000..14ba828b Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_72.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_73.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_73.png new file mode 100644 index 00000000..8e2f3623 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_73.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_74.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_74.png new file mode 100644 index 00000000..19cc93dc Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_74.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_75.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_75.png new file mode 100644 index 00000000..c97dd147 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_75.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_76.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_76.png new file mode 100644 index 00000000..a9f566ee Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_76.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_77.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_77.png new file mode 100644 index 00000000..07365ce8 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_77.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_78.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_78.png new file mode 100644 index 00000000..3fb7813e Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_78.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_79.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_79.png new file mode 100644 index 00000000..15cceb8b Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_79.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_80.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_80.png new file mode 100644 index 00000000..bff269fe Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_80.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_81.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_81.png new file mode 100644 index 00000000..b685bb05 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_81.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_82.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_82.png new file mode 100644 index 00000000..29903742 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_82.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_83.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_83.png new file mode 100644 index 00000000..bd370640 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_83.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_84.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_84.png new file mode 100644 index 00000000..a6f58ed9 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Default/pattern_84.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_01.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_01.png new file mode 100644 index 00000000..c4fee1e2 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_01.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_02.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_02.png new file mode 100644 index 00000000..598cfda2 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_02.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_03.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_03.png new file mode 100644 index 00000000..7fc30b65 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_03.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_04.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_04.png new file mode 100644 index 00000000..988191e3 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_04.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_05.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_05.png new file mode 100644 index 00000000..808d90c7 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_05.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_06.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_06.png new file mode 100644 index 00000000..d84be910 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_06.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_07.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_07.png new file mode 100644 index 00000000..f6988b54 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_07.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_08.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_08.png new file mode 100644 index 00000000..14410965 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_08.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_09.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_09.png new file mode 100644 index 00000000..d68d2f69 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_09.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_10.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_10.png new file mode 100644 index 00000000..4f54ed35 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_10.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_11.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_11.png new file mode 100644 index 00000000..6e1abb22 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_11.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_12.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_12.png new file mode 100644 index 00000000..65fc4e83 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_12.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_13.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_13.png new file mode 100644 index 00000000..d3832416 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_13.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_14.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_14.png new file mode 100644 index 00000000..464a9ec1 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_14.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_15.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_15.png new file mode 100644 index 00000000..698eb057 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_15.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_16.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_16.png new file mode 100644 index 00000000..d66cdee5 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_16.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_17.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_17.png new file mode 100644 index 00000000..628e6da9 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_17.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_18.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_18.png new file mode 100644 index 00000000..da159ff0 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_18.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_19.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_19.png new file mode 100644 index 00000000..ec2923d5 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_19.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_20.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_20.png new file mode 100644 index 00000000..89744b88 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_20.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_21.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_21.png new file mode 100644 index 00000000..b357b9cd Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_21.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_22.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_22.png new file mode 100644 index 00000000..67e6f8fa Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_22.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_23.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_23.png new file mode 100644 index 00000000..dc925fb8 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_23.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_24.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_24.png new file mode 100644 index 00000000..6f9eb386 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_24.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_25.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_25.png new file mode 100644 index 00000000..309fa1cc Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_25.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_26.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_26.png new file mode 100644 index 00000000..5cfd7321 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_26.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_27.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_27.png new file mode 100644 index 00000000..7c90fbb6 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_27.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_28.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_28.png new file mode 100644 index 00000000..8d97a4c1 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_28.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_29.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_29.png new file mode 100644 index 00000000..91922049 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_29.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_30.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_30.png new file mode 100644 index 00000000..4cc24837 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_30.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_31.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_31.png new file mode 100644 index 00000000..cb08fec4 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_31.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_32.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_32.png new file mode 100644 index 00000000..4e5d1f7f Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_32.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_33.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_33.png new file mode 100644 index 00000000..0ba0e13c Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_33.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_34.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_34.png new file mode 100644 index 00000000..77a2ba0d Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_34.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_35.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_35.png new file mode 100644 index 00000000..290a39d8 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_35.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_36.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_36.png new file mode 100644 index 00000000..638004f1 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_36.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_37.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_37.png new file mode 100644 index 00000000..4f44983d Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_37.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_38.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_38.png new file mode 100644 index 00000000..854a4a02 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_38.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_39.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_39.png new file mode 100644 index 00000000..911b3892 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_39.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_40.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_40.png new file mode 100644 index 00000000..d921fb22 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_40.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_41.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_41.png new file mode 100644 index 00000000..037b0b89 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_41.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_42.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_42.png new file mode 100644 index 00000000..0f6c3ebb Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_42.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_43.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_43.png new file mode 100644 index 00000000..98a3b88d Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_43.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_44.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_44.png new file mode 100644 index 00000000..2746dda8 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_44.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_45.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_45.png new file mode 100644 index 00000000..fd3385da Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_45.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_46.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_46.png new file mode 100644 index 00000000..473b07d1 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_46.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_47.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_47.png new file mode 100644 index 00000000..1bc4648e Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_47.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_48.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_48.png new file mode 100644 index 00000000..dfe94612 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_48.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_49.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_49.png new file mode 100644 index 00000000..ddc079ff Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_49.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_50.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_50.png new file mode 100644 index 00000000..aa70e2db Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_50.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_51.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_51.png new file mode 100644 index 00000000..6ebb14d5 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_51.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_52.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_52.png new file mode 100644 index 00000000..f5df1ed8 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_52.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_53.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_53.png new file mode 100644 index 00000000..2ee954eb Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_53.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_54.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_54.png new file mode 100644 index 00000000..201127c7 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_54.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_55.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_55.png new file mode 100644 index 00000000..966fdcc0 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_55.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_56.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_56.png new file mode 100644 index 00000000..ebd6c0cd Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_56.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_57.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_57.png new file mode 100644 index 00000000..6e77184b Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_57.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_58.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_58.png new file mode 100644 index 00000000..6475856b Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_58.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_59.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_59.png new file mode 100644 index 00000000..3a790b7a Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_59.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_60.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_60.png new file mode 100644 index 00000000..c05e54d6 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_60.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_61.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_61.png new file mode 100644 index 00000000..9fd180ec Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_61.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_62.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_62.png new file mode 100644 index 00000000..d014a77e Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_62.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_63.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_63.png new file mode 100644 index 00000000..fecb6d47 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_63.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_64.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_64.png new file mode 100644 index 00000000..739c120f Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_64.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_65.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_65.png new file mode 100644 index 00000000..957f57a3 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_65.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_66.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_66.png new file mode 100644 index 00000000..fcdc1aba Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_66.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_67.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_67.png new file mode 100644 index 00000000..45dd3161 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_67.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_68.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_68.png new file mode 100644 index 00000000..3b96d097 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_68.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_69.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_69.png new file mode 100644 index 00000000..d185d3b4 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_69.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_70.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_70.png new file mode 100644 index 00000000..ddc13056 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_70.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_71.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_71.png new file mode 100644 index 00000000..e62e9595 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_71.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_72.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_72.png new file mode 100644 index 00000000..4682a7a2 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_72.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_73.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_73.png new file mode 100644 index 00000000..fc427385 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_73.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_74.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_74.png new file mode 100644 index 00000000..95018356 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_74.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_75.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_75.png new file mode 100644 index 00000000..d327d38f Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_75.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_76.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_76.png new file mode 100644 index 00000000..e07ef385 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_76.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_77.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_77.png new file mode 100644 index 00000000..c4bba5e9 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_77.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_78.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_78.png new file mode 100644 index 00000000..dc2dad9a Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_78.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_79.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_79.png new file mode 100644 index 00000000..55229506 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_79.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_80.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_80.png new file mode 100644 index 00000000..ebad5097 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_80.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_81.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_81.png new file mode 100644 index 00000000..3467b58d Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_81.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_82.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_82.png new file mode 100644 index 00000000..113edd0d Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_82.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_83.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_83.png new file mode 100644 index 00000000..0cc663e4 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_83.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_84.png b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_84.png new file mode 100644 index 00000000..760d0bc0 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/PNG/Double/pattern_84.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/Preview.png b/documentation-site/static/img/kenney_pattern-pack/Preview.png new file mode 100644 index 00000000..6dbc10d9 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/Preview.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/Tilesheet/patternPack_tilesheet.png b/documentation-site/static/img/kenney_pattern-pack/Tilesheet/patternPack_tilesheet.png new file mode 100644 index 00000000..8a28947a Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/Tilesheet/patternPack_tilesheet.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/Tilesheet/patternPack_tilesheet@2.png b/documentation-site/static/img/kenney_pattern-pack/Tilesheet/patternPack_tilesheet@2.png new file mode 100644 index 00000000..70b85bc0 Binary files /dev/null and b/documentation-site/static/img/kenney_pattern-pack/Tilesheet/patternPack_tilesheet@2.png differ diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_01.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_01.svg new file mode 100644 index 00000000..eb2fc0f9 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_01.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_02.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_02.svg new file mode 100644 index 00000000..6f079f10 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_02.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_03.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_03.svg new file mode 100644 index 00000000..9dca55c9 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_03.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_04.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_04.svg new file mode 100644 index 00000000..19c9bd0a --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_04.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_05.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_05.svg new file mode 100644 index 00000000..59eaabe2 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_05.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_06.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_06.svg new file mode 100644 index 00000000..9632cf3e --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_06.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_07.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_07.svg new file mode 100644 index 00000000..6f0bd013 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_07.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_08.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_08.svg new file mode 100644 index 00000000..41f289cf --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_08.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_09.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_09.svg new file mode 100644 index 00000000..fe0f2425 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_09.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_10.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_10.svg new file mode 100644 index 00000000..67ec5bad --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_10.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_11.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_11.svg new file mode 100644 index 00000000..25a2a563 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_11.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_12.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_12.svg new file mode 100644 index 00000000..7da1ac2a --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_12.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_13.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_13.svg new file mode 100644 index 00000000..e87dc9c5 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_13.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_14.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_14.svg new file mode 100644 index 00000000..d1a6b06c --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_14.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_15.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_15.svg new file mode 100644 index 00000000..fa6f0814 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_15.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_16.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_16.svg new file mode 100644 index 00000000..e55cb734 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_16.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_17.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_17.svg new file mode 100644 index 00000000..351b4479 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_17.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_18.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_18.svg new file mode 100644 index 00000000..7212b700 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_18.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_19.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_19.svg new file mode 100644 index 00000000..bfbefee4 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_19.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_20.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_20.svg new file mode 100644 index 00000000..c3cb4250 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_20.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_21.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_21.svg new file mode 100644 index 00000000..5a124493 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_21.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_22.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_22.svg new file mode 100644 index 00000000..d6ee96b2 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_22.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_23.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_23.svg new file mode 100644 index 00000000..6a98eba6 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_23.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_24.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_24.svg new file mode 100644 index 00000000..72d76514 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_24.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_25.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_25.svg new file mode 100644 index 00000000..c247570a --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_25.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_26.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_26.svg new file mode 100644 index 00000000..aa84dbc6 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_26.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_27.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_27.svg new file mode 100644 index 00000000..52fa49af --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_27.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_28.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_28.svg new file mode 100644 index 00000000..291a0ed8 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_28.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_29.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_29.svg new file mode 100644 index 00000000..941893e7 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_29.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_30.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_30.svg new file mode 100644 index 00000000..5fff9a49 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_30.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_31.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_31.svg new file mode 100644 index 00000000..011ef798 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_31.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_32.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_32.svg new file mode 100644 index 00000000..fcb2b55f --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_32.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_33.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_33.svg new file mode 100644 index 00000000..5f73daef --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_33.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_34.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_34.svg new file mode 100644 index 00000000..886a7066 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_34.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_35.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_35.svg new file mode 100644 index 00000000..239118c2 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_35.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_36.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_36.svg new file mode 100644 index 00000000..ac933f2e --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_36.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_37.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_37.svg new file mode 100644 index 00000000..d0190aab --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_37.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_38.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_38.svg new file mode 100644 index 00000000..34b64cb1 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_38.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_39.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_39.svg new file mode 100644 index 00000000..011d0243 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_39.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_40.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_40.svg new file mode 100644 index 00000000..60cf46da --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_40.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_41.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_41.svg new file mode 100644 index 00000000..472c7eb9 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_41.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_42.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_42.svg new file mode 100644 index 00000000..24ea06de --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_42.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_43.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_43.svg new file mode 100644 index 00000000..4ff24c57 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_43.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_44.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_44.svg new file mode 100644 index 00000000..cbcecc27 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_44.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_45.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_45.svg new file mode 100644 index 00000000..ac5d4a0a --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_45.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_46.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_46.svg new file mode 100644 index 00000000..36b5f755 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_46.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_47.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_47.svg new file mode 100644 index 00000000..6eb899b4 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_47.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_48.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_48.svg new file mode 100644 index 00000000..7c1a88ae --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_48.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_49.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_49.svg new file mode 100644 index 00000000..bcc19785 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_49.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_50.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_50.svg new file mode 100644 index 00000000..5604adee --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_50.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_51.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_51.svg new file mode 100644 index 00000000..dec9fcf4 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_51.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_52.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_52.svg new file mode 100644 index 00000000..df6c06ce --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_52.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_53.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_53.svg new file mode 100644 index 00000000..73645b4c --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_53.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_54.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_54.svg new file mode 100644 index 00000000..73acca06 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_54.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_55.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_55.svg new file mode 100644 index 00000000..289286e2 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_55.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_56.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_56.svg new file mode 100644 index 00000000..17acb9dd --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_56.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_57.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_57.svg new file mode 100644 index 00000000..257eb3dc --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_57.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_58.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_58.svg new file mode 100644 index 00000000..ef29c228 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_58.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_59.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_59.svg new file mode 100644 index 00000000..9bdfb428 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_59.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_60.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_60.svg new file mode 100644 index 00000000..9605aa61 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_60.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_61.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_61.svg new file mode 100644 index 00000000..83e5cc9c --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_61.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_62.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_62.svg new file mode 100644 index 00000000..6cddd69c --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_62.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_63.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_63.svg new file mode 100644 index 00000000..1c1fe6de --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_63.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_64.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_64.svg new file mode 100644 index 00000000..aacd44dd --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_64.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_65.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_65.svg new file mode 100644 index 00000000..2867969f --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_65.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_66.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_66.svg new file mode 100644 index 00000000..9f5af42a --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_66.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_67.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_67.svg new file mode 100644 index 00000000..5c1994ed --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_67.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_68.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_68.svg new file mode 100644 index 00000000..71a84771 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_68.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_69.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_69.svg new file mode 100644 index 00000000..3062bedf --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_69.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_70.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_70.svg new file mode 100644 index 00000000..767e9c6d --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_70.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_71.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_71.svg new file mode 100644 index 00000000..17413b76 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_71.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_72.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_72.svg new file mode 100644 index 00000000..0dfbb82d --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_72.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_73.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_73.svg new file mode 100644 index 00000000..cfcfde97 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_73.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_74.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_74.svg new file mode 100644 index 00000000..ada4712e --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_74.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_75.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_75.svg new file mode 100644 index 00000000..7721a5dc --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_75.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_76.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_76.svg new file mode 100644 index 00000000..599d737f --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_76.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_77.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_77.svg new file mode 100644 index 00000000..a4efb934 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_77.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_78.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_78.svg new file mode 100644 index 00000000..f697350a --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_78.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_79.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_79.svg new file mode 100644 index 00000000..052939be --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_79.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_80.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_80.svg new file mode 100644 index 00000000..9e330abe --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_80.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_81.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_81.svg new file mode 100644 index 00000000..5c9fd8fe --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_81.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_82.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_82.svg new file mode 100644 index 00000000..59caf45e --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_82.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_83.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_83.svg new file mode 100644 index 00000000..de1062f7 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_83.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_84.svg b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_84.svg new file mode 100644 index 00000000..e9091bf3 --- /dev/null +++ b/documentation-site/static/img/kenney_pattern-pack/Vector/pattern_84.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/physics/collision/circle-polygon-contact.ts b/src/physics/collision/circle-polygon-contact.ts new file mode 100644 index 00000000..da423fce --- /dev/null +++ b/src/physics/collision/circle-polygon-contact.ts @@ -0,0 +1,156 @@ +import type { Vector2 } from '../../math/index.js'; + +const EPSILON = 1e-9; + +/** + * The face of a convex polygon (in the polygon's own local space) with the + * largest separation from a circle's local-space center, found by + * {@link findClosestFace}. + */ +export interface FaceSeparation { + /** + * The signed distance from the face to the circle's center, along the + * face's normal. Positive when the center is outside the face. + */ + separation: number; + + /** + * The index of the face (and its normal) within the polygon's + * `vertices`/`normals` arrays. + */ + faceIndex: number; +} + +/** + * A circle's contact against a convex polygon face or vertex, in the + * polygon's own local space, found by {@link findCircleContact}. + */ +export interface CircleContact { + /** + * The contact normal, pointing from the polygon toward the circle. + */ + localNormal: Vector2; + + /** + * The contact point, on the surface of the polygon. + */ + localContactPoint: Vector2; + + /** + * The penetration depth of the contact. + */ + depth: number; +} + +/** + * Finds the polygon face with the largest separation from `localCenter` + * (a circle's center, in the polygon's own local space), for use as the + * circle's reference face. Returns `null` if the circle's `radius` cannot + * reach any face, meaning the shapes do not overlap. + * @param vertices - The polygon's vertices, in its own local space. + * @param normals - The polygon's outward-facing edge normals, matching + * `vertices`. + * @param localCenter - The circle's center, in the polygon's local space. + * @param radius - The circle's radius. + * @returns The closest face and its separation, or `null` if the shapes + * cannot overlap. + */ +export function findClosestFace( + vertices: readonly Vector2[], + normals: readonly Vector2[], + localCenter: Vector2, + radius: number, +): FaceSeparation | null { + let separation = -Infinity; + let faceIndex = 0; + + for (let i = 0; i < vertices.length; i++) { + const faceSeparation = normals[i].dot(localCenter.subtract(vertices[i])); + + if (faceSeparation > radius) { + return null; + } + + if (faceSeparation > separation) { + separation = faceSeparation; + faceIndex = i; + } + } + + return { separation, faceIndex }; +} + +function faceContact( + normal: Vector2, + localCenter: Vector2, + separation: number, + radius: number, +): CircleContact { + return { + localNormal: normal, + localContactPoint: localCenter.subtract(normal.multiply(separation)), + depth: radius - separation, + }; +} + +function vertexContact( + localCenter: Vector2, + vertex: Vector2, + radius: number, +): CircleContact | null { + const distance = localCenter.distanceTo(vertex); + + if (distance > radius) { + return null; + } + + return { + localNormal: localCenter.subtract(vertex).normalize(), + localContactPoint: vertex, + depth: radius - distance, + }; +} + +/** + * Resolves the exact contact (face or vertex) between a circle and the + * polygon face found by {@link findClosestFace}, in the polygon's own + * local space. + * @param vertices - The polygon's vertices, in its own local space. + * @param normals - The polygon's outward-facing edge normals, matching + * `vertices`. + * @param localCenter - The circle's center, in the polygon's local space. + * @param faceIndex - The closest face's index, from {@link findClosestFace}. + * @param separation - The closest face's separation, from + * {@link findClosestFace}. + * @param radius - The circle's radius. + * @returns The resolved contact, or `null` if the shapes do not actually + * overlap (the circle's center lies beyond a vertex, out of `radius`). + */ +export function findCircleContact( + vertices: readonly Vector2[], + normals: readonly Vector2[], + localCenter: Vector2, + faceIndex: number, + separation: number, + radius: number, +): CircleContact | null { + if (separation < EPSILON) { + return faceContact(normals[faceIndex], localCenter, separation, radius); + } + + const v1 = vertices[faceIndex]; + const v2 = vertices[(faceIndex + 1) % vertices.length]; + const u1 = localCenter.subtract(v1).dot(v2.subtract(v1)); + + if (u1 <= 0) { + return vertexContact(localCenter, v1, radius); + } + + const u2 = localCenter.subtract(v2).dot(v1.subtract(v2)); + + if (u2 <= 0) { + return vertexContact(localCenter, v2, radius); + } + + return faceContact(normals[faceIndex], localCenter, separation, radius); +} diff --git a/src/physics/collision/detect-circle-polygon-collision.ts b/src/physics/collision/detect-circle-polygon-collision.ts index 09e0c32d..0e6b18f9 100644 --- a/src/physics/collision/detect-circle-polygon-collision.ts +++ b/src/physics/collision/detect-circle-polygon-collision.ts @@ -1,106 +1,11 @@ -import type { Vector2 } from '../../math/index.js'; import type { RigidBody } from '../rigid-body.js'; import type { CircleShape, PolygonShape } from '../shapes/index.js'; +import { + findCircleContact, + findClosestFace, +} from './circle-polygon-contact.js'; import type { CollisionManifold } from './collision-manifold.js'; -const EPSILON = 1e-9; - -interface FaceSeparation { - separation: number; - faceIndex: number; -} - -interface CircleContact { - localNormal: Vector2; - localContactPoint: Vector2; - depth: number; -} - -function findClosestFace( - vertices: readonly Vector2[], - normals: readonly Vector2[], - localCenter: Vector2, - radius: number, -): FaceSeparation | null { - let separation = -Infinity; - let faceIndex = 0; - - for (let i = 0; i < vertices.length; i++) { - const faceSeparation = normals[i].dot(localCenter.subtract(vertices[i])); - - if (faceSeparation > radius) { - return null; - } - - if (faceSeparation > separation) { - separation = faceSeparation; - faceIndex = i; - } - } - - return { separation, faceIndex }; -} - -function faceContact( - normal: Vector2, - localCenter: Vector2, - separation: number, - radius: number, -): CircleContact { - return { - localNormal: normal, - localContactPoint: localCenter.subtract(normal.multiply(separation)), - depth: radius - separation, - }; -} - -function vertexContact( - localCenter: Vector2, - vertex: Vector2, - radius: number, -): CircleContact | null { - const distance = localCenter.distanceTo(vertex); - - if (distance > radius) { - return null; - } - - return { - localNormal: localCenter.subtract(vertex).normalize(), - localContactPoint: vertex, - depth: radius - distance, - }; -} - -function findCircleContact( - vertices: readonly Vector2[], - normals: readonly Vector2[], - localCenter: Vector2, - faceIndex: number, - separation: number, - radius: number, -): CircleContact | null { - if (separation < EPSILON) { - return faceContact(normals[faceIndex], localCenter, separation, radius); - } - - const v1 = vertices[faceIndex]; - const v2 = vertices[(faceIndex + 1) % vertices.length]; - const u1 = localCenter.subtract(v1).dot(v2.subtract(v1)); - - if (u1 <= 0) { - return vertexContact(localCenter, v1, radius); - } - - const u2 = localCenter.subtract(v2).dot(v1.subtract(v2)); - - if (u2 <= 0) { - return vertexContact(localCenter, v2, radius); - } - - return faceContact(normals[faceIndex], localCenter, separation, radius); -} - /** * Detects a collision between a circle-shaped body and a polygon-shaped * body. diff --git a/src/physics/collision/detect-circle-terrain-collision.test.ts b/src/physics/collision/detect-circle-terrain-collision.test.ts new file mode 100644 index 00000000..9c964f2e --- /dev/null +++ b/src/physics/collision/detect-circle-terrain-collision.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { detectCircleTerrainCollision } from './detect-circle-terrain-collision.js'; +import { RigidBody } from '../rigid-body.js'; +import { CircleShape, TerrainShape } from '../shapes/index.js'; +import { Vector2 } from '../../math/index.js'; + +function flatTerrainBody(): RigidBody { + return new RigidBody({ + shape: new TerrainShape( + [new Vector2(-5, 0), new Vector2(0, 0), new Vector2(5, 0)], + 2, + ), + position: Vector2.zero, + isStatic: true, + }); +} + +describe('detectCircleTerrainCollision', () => { + it('should return null when the circle is far from the terrain', () => { + const circleBody = new RigidBody({ + shape: new CircleShape(1), + position: new Vector2(0, -10), + }); + + expect( + detectCircleTerrainCollision(circleBody, flatTerrainBody()), + ).toBeNull(); + }); + + it('should return null when the circle is outside the terrain x-range', () => { + const circleBody = new RigidBody({ + shape: new CircleShape(1), + position: new Vector2(20, -0.5), + }); + + expect( + detectCircleTerrainCollision(circleBody, flatTerrainBody()), + ).toBeNull(); + }); + + it('should detect a face collision resting on the terrain surface', () => { + const circleBody = new RigidBody({ + shape: new CircleShape(1), + position: new Vector2(0, -0.5), + }); + + const manifold = detectCircleTerrainCollision( + circleBody, + flatTerrainBody(), + ); + + expect(manifold).not.toBeNull(); + expect(manifold?.bodyA).toBe(circleBody); + expect(manifold?.normal.x).toBeCloseTo(0); + expect(manifold?.normal.y).toBeCloseTo(1); + expect(manifold?.depth).toBeCloseTo(0.5); + expect(manifold?.contactPoints[0].y).toBeCloseTo(0); + }); + + it('should detect a collision spanning the boundary between two segments', () => { + const terrainBody = flatTerrainBody(); + const circleBody = new RigidBody({ + shape: new CircleShape(1), + position: new Vector2(0, -0.5), + }); + + const manifold = detectCircleTerrainCollision(circleBody, terrainBody); + + expect(manifold).not.toBeNull(); + expect(manifold?.depth).toBeCloseTo(0.5); + }); + + it('should account for the terrain body position offset', () => { + const terrainBody = new RigidBody({ + shape: new TerrainShape([new Vector2(-5, 0), new Vector2(5, 0)], 2), + position: new Vector2(100, 50), + isStatic: true, + }); + const circleBody = new RigidBody({ + shape: new CircleShape(1), + position: new Vector2(100, 49.5), + }); + + const manifold = detectCircleTerrainCollision(circleBody, terrainBody); + + expect(manifold).not.toBeNull(); + expect(manifold?.depth).toBeCloseTo(0.5); + }); + + it('should detect a collision against a sloped segment', () => { + const terrainBody = new RigidBody({ + shape: new TerrainShape([new Vector2(0, 0), new Vector2(10, 10)], 5), + position: Vector2.zero, + isStatic: true, + }); + + // Resting just above the slope's midpoint, along its outward normal. + const midpoint = new Vector2(5, 5); + const normal = new Vector2(1, -1).normalize(); + const circleBody = new RigidBody({ + shape: new CircleShape(1), + position: midpoint.add(normal.multiply(0.5)), + }); + + const manifold = detectCircleTerrainCollision(circleBody, terrainBody); + + expect(manifold).not.toBeNull(); + expect(manifold?.depth).toBeCloseTo(0.5); + + // The manifold's normal points from bodyA (the circle) toward bodyB + // (the terrain), i.e. into the ground - the opposite of the outward + // normal the circle was placed along. + expect(manifold?.normal.x).toBeCloseTo(-normal.x); + expect(manifold?.normal.y).toBeCloseTo(-normal.y); + }); +}); diff --git a/src/physics/collision/detect-circle-terrain-collision.ts b/src/physics/collision/detect-circle-terrain-collision.ts new file mode 100644 index 00000000..665bc352 --- /dev/null +++ b/src/physics/collision/detect-circle-terrain-collision.ts @@ -0,0 +1,96 @@ +import type { Vector2 } from '../../math/index.js'; +import type { RigidBody } from '../rigid-body.js'; +import type { CircleShape, TerrainShape } from '../shapes/index.js'; +import { + findCircleContact, + findClosestFace, +} from './circle-polygon-contact.js'; +import type { CollisionManifold } from './collision-manifold.js'; + +interface BestContact { + localNormal: Vector2; + localContactPoint: Vector2; + depth: number; +} + +/** + * Detects a collision between a circle-shaped body and a terrain-shaped + * body, by running the same circle-vs-convex-polygon narrow phase used for + * {@link detectCirclePolygonCollision} against each of the terrain's + * segments that overlap the circle's local x-range, keeping the deepest + * resulting contact. + * @param circleBody - The body with a {@link CircleShape}. Becomes + * `bodyA` of the returned manifold. + * @param terrainBody - The body with a {@link TerrainShape}. Becomes + * `bodyB` of the returned manifold. + * @returns A {@link CollisionManifold} if the shapes overlap, otherwise + * `null`. + */ +export function detectCircleTerrainCollision( + circleBody: RigidBody, + terrainBody: RigidBody, +): CollisionManifold | null { + const circleShape = circleBody.shape as CircleShape; + const terrainShape = terrainBody.shape as TerrainShape; + const { radius } = circleShape; + + const localCenter = circleBody.position + .subtract(terrainBody.position) + .rotate(-terrainBody.angle); + + let best: BestContact | null = null; + + for (const segment of terrainShape.segments) { + if ( + localCenter.x + radius < segment.minX || + localCenter.x - radius > segment.maxX + ) { + continue; + } + + const { vertices, normals } = segment; + const closestFace = findClosestFace(vertices, normals, localCenter, radius); + + if (closestFace === null) { + continue; + } + + const contact = findCircleContact( + vertices, + normals, + localCenter, + closestFace.faceIndex, + closestFace.separation, + radius, + ); + + if (contact === null) { + continue; + } + + if (best === null || contact.depth > best.depth) { + best = { + localNormal: contact.localNormal, + localContactPoint: contact.localContactPoint, + depth: contact.depth, + }; + } + } + + if (best === null) { + return null; + } + + const normal = best.localNormal.rotate(terrainBody.angle).negate(); + const contactPoint = best.localContactPoint + .rotate(terrainBody.angle) + .add(terrainBody.position); + + return { + bodyA: circleBody, + bodyB: terrainBody, + normal, + depth: best.depth, + contactPoints: [contactPoint], + }; +} diff --git a/src/physics/collision/detect-collision.test.ts b/src/physics/collision/detect-collision.test.ts index 67341544..9b732536 100644 --- a/src/physics/collision/detect-collision.test.ts +++ b/src/physics/collision/detect-collision.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { detectCollision } from './detect-collision.js'; import { RigidBody } from '../rigid-body.js'; -import { CircleShape, PolygonShape } from '../shapes/index.js'; +import { CircleShape, PolygonShape, TerrainShape } from '../shapes/index.js'; import { Vector2 } from '../../math/index.js'; describe('detectCollision', () => { @@ -80,6 +80,86 @@ describe('detectCollision', () => { expect(manifold?.normal.y).toBeCloseTo(0); }); + it('should dispatch circle-terrain collisions', () => { + const bodyA = new RigidBody({ + shape: new CircleShape(1), + position: new Vector2(0, -0.5), + }); + const bodyB = new RigidBody({ + shape: new TerrainShape([new Vector2(-5, 0), new Vector2(5, 0)], 2), + position: Vector2.zero, + isStatic: true, + }); + + const manifold = detectCollision(bodyA, bodyB); + + expect(manifold).not.toBeNull(); + expect(manifold?.bodyA).toBe(bodyA); + expect(manifold?.bodyB).toBe(bodyB); + expect(manifold?.normal.x).toBeCloseTo(0); + expect(manifold?.normal.y).toBeCloseTo(1); + }); + + it('should dispatch terrain-circle collisions, preserving body order', () => { + const bodyA = new RigidBody({ + shape: new TerrainShape([new Vector2(-5, 0), new Vector2(5, 0)], 2), + position: Vector2.zero, + isStatic: true, + }); + const bodyB = new RigidBody({ + shape: new CircleShape(1), + position: new Vector2(0, -0.5), + }); + + const manifold = detectCollision(bodyA, bodyB); + + expect(manifold).not.toBeNull(); + expect(manifold?.bodyA).toBe(bodyA); + expect(manifold?.bodyB).toBe(bodyB); + expect(manifold?.normal.x).toBeCloseTo(0); + expect(manifold?.normal.y).toBeCloseTo(-1); + }); + + it('should dispatch polygon-terrain collisions', () => { + const bodyA = new RigidBody({ + shape: PolygonShape.rectangle(2, 2), + position: new Vector2(0, -0.5), + }); + const bodyB = new RigidBody({ + shape: new TerrainShape([new Vector2(-5, 0), new Vector2(5, 0)], 2), + position: Vector2.zero, + isStatic: true, + }); + + const manifold = detectCollision(bodyA, bodyB); + + expect(manifold).not.toBeNull(); + expect(manifold?.bodyA).toBe(bodyA); + expect(manifold?.bodyB).toBe(bodyB); + expect(manifold?.normal.x).toBeCloseTo(0); + expect(manifold?.normal.y).toBeCloseTo(1); + }); + + it('should dispatch terrain-polygon collisions, preserving body order', () => { + const bodyA = new RigidBody({ + shape: new TerrainShape([new Vector2(-5, 0), new Vector2(5, 0)], 2), + position: Vector2.zero, + isStatic: true, + }); + const bodyB = new RigidBody({ + shape: PolygonShape.rectangle(2, 2), + position: new Vector2(0, -0.5), + }); + + const manifold = detectCollision(bodyA, bodyB); + + expect(manifold).not.toBeNull(); + expect(manifold?.bodyA).toBe(bodyA); + expect(manifold?.bodyB).toBe(bodyB); + expect(manifold?.normal.x).toBeCloseTo(0); + expect(manifold?.normal.y).toBeCloseTo(-1); + }); + it('should throw an error for an unregistered shape pair', () => { const bodyA = new RigidBody({ shape: new CircleShape(1) }); const fakeBody = { diff --git a/src/physics/collision/detect-collision.ts b/src/physics/collision/detect-collision.ts index c6f452fa..01c5030b 100644 --- a/src/physics/collision/detect-collision.ts +++ b/src/physics/collision/detect-collision.ts @@ -2,7 +2,9 @@ import type { RigidBody } from '../rigid-body.js'; import type { CollisionManifold } from './collision-manifold.js'; import { detectCircleCircleCollision } from './detect-circle-circle-collision.js'; import { detectCirclePolygonCollision } from './detect-circle-polygon-collision.js'; +import { detectCircleTerrainCollision } from './detect-circle-terrain-collision.js'; import { detectPolygonPolygonCollision } from './detect-polygon-polygon-collision.js'; +import { detectPolygonTerrainCollision } from './detect-polygon-terrain-collision.js'; function flipManifold( manifold: CollisionManifold | null, @@ -31,6 +33,16 @@ const collisionDetectors = new Map< (bodyA, bodyB) => flipManifold(detectCirclePolygonCollision(bodyB, bodyA)), ], ['polygon-polygon', detectPolygonPolygonCollision], + ['circle-terrain', detectCircleTerrainCollision], + [ + 'terrain-circle', + (bodyA, bodyB) => flipManifold(detectCircleTerrainCollision(bodyB, bodyA)), + ], + ['polygon-terrain', detectPolygonTerrainCollision], + [ + 'terrain-polygon', + (bodyA, bodyB) => flipManifold(detectPolygonTerrainCollision(bodyB, bodyA)), + ], ]); /** diff --git a/src/physics/collision/detect-polygon-polygon-collision.ts b/src/physics/collision/detect-polygon-polygon-collision.ts index 2628767f..e368e686 100644 --- a/src/physics/collision/detect-polygon-polygon-collision.ts +++ b/src/physics/collision/detect-polygon-polygon-collision.ts @@ -1,225 +1,10 @@ -import { Vector2 } from '../../math/index.js'; import type { RigidBody } from '../rigid-body.js'; import type { PolygonShape } from '../shapes/index.js'; import type { CollisionManifold } from './collision-manifold.js'; - -const RELATIVE_TOLERANCE = 0.95; -const ABSOLUTE_TOLERANCE = 0.01; - -interface PolygonFaces { - vertices: Vector2[]; - normals: Vector2[]; -} - -interface Axis { - separation: number; - faceIndex: number; -} - -interface ReferenceIncidentFaces { - reference: PolygonFaces; - incident: PolygonFaces; - faceIndex: number; - flip: boolean; -} - -/** - * Returns the vertex that is furthest along the given direction. - */ -function getSupportPoint(vertices: Vector2[], direction: Vector2): Vector2 { - let bestVertex = vertices[0]; - let bestProjection = direction.dot(bestVertex); - - for (let i = 1; i < vertices.length; i++) { - const projection = direction.dot(vertices[i]); - - if (projection > bestProjection) { - bestVertex = vertices[i]; - bestProjection = projection; - } - } - - return bestVertex; -} - -/** - * Finds the face of the first polygon whose normal yields the largest - * separation from the second polygon. A positive separation means the - * polygons do not overlap along that axis. - */ -function findAxisOfLeastPenetration( - ownVertices: Vector2[], - ownNormals: Vector2[], - otherVertices: Vector2[], -): Axis { - let bestSeparation = -Infinity; - let bestFaceIndex = 0; - - for (let i = 0; i < ownNormals.length; i++) { - const normal = ownNormals[i]; - const supportPoint = getSupportPoint(otherVertices, normal.negate()); - const separation = normal.dot(supportPoint.subtract(ownVertices[i])); - - if (separation > bestSeparation) { - bestSeparation = separation; - bestFaceIndex = i; - } - } - - return { separation: bestSeparation, faceIndex: bestFaceIndex }; -} - -/** - * Picks which polygon's face of least penetration acts as the reference - * face, biasing toward the first polygon to avoid face-flip jitter when the - * separations are nearly equal. - */ -function selectReferenceFace( - polygonA: PolygonFaces, - polygonB: PolygonFaces, - axisA: Axis, - axisB: Axis, -): ReferenceIncidentFaces { - const flip = - axisB.separation > - RELATIVE_TOLERANCE * axisA.separation + ABSOLUTE_TOLERANCE; - - if (flip) { - return { - reference: polygonB, - incident: polygonA, - faceIndex: axisB.faceIndex, - flip, - }; - } - - return { - reference: polygonA, - incident: polygonB, - faceIndex: axisA.faceIndex, - flip, - }; -} - -/** - * Finds the incident polygon's face whose normal is most anti-parallel to - * the reference face's normal. - */ -function findIncidentFaceIndex( - referenceNormal: Vector2, - incidentNormals: Vector2[], -): number { - let incidentFaceIndex = 0; - let minDot = Infinity; - - for (let i = 0; i < incidentNormals.length; i++) { - const dot = referenceNormal.dot(incidentNormals[i]); - - if (dot < minDot) { - minDot = dot; - incidentFaceIndex = i; - } - } - - return incidentFaceIndex; -} - -/** - * Clips the segment `v1`-`v2` against the half-plane - * `normal . point <= offset`, returning the points that lie within it - * (including any interpolated intersection point). - */ -function clip( - v1: Vector2, - v2: Vector2, - normal: Vector2, - offset: number, -): Vector2[] { - const result: Vector2[] = []; - - const distance1 = normal.dot(v1) - offset; - const distance2 = normal.dot(v2) - offset; - - if (distance1 <= 0) { - result.push(v1); - } - - if (distance2 <= 0) { - result.push(v2); - } - - if (distance1 * distance2 < 0) { - const t = distance1 / (distance1 - distance2); - - result.push(v1.add(v2.subtract(v1).multiply(t))); - } - - return result; -} - -/** - * Clips the incident edge against the two side planes of the reference - * face, returning `null` if the incident edge lies entirely outside either - * side plane. - */ -function clipIncidentEdge( - incidentV1: Vector2, - incidentV2: Vector2, - referenceV1: Vector2, - referenceV2: Vector2, -): Vector2[] | null { - const tangent = referenceV2.subtract(referenceV1).normalize(); - const negativeSideOffset = -tangent.dot(referenceV1); - const positiveSideOffset = tangent.dot(referenceV2); - - let clippedPoints = clip( - incidentV1, - incidentV2, - tangent.negate(), - negativeSideOffset, - ); - - if (clippedPoints.length < 2) { - return null; - } - - clippedPoints = clip( - clippedPoints[0], - clippedPoints[1], - tangent, - positiveSideOffset, - ); - - if (clippedPoints.length < 2) { - return null; - } - - return clippedPoints; -} - -/** - * Discards clipped points that are not penetrating the reference face and - * computes the penetration depth of the remaining contact points. - */ -function findContactPoints( - clippedPoints: Vector2[], - referenceNormal: Vector2, - referenceV1: Vector2, -): { contactPoints: Vector2[]; depth: number } { - const contactPoints: Vector2[] = []; - let depth = 0; - - for (const point of clippedPoints) { - const separation = referenceNormal.dot(point.subtract(referenceV1)); - - if (separation <= 0) { - contactPoints.push(point); - depth = Math.max(depth, -separation); - } - } - - return { contactPoints, depth }; -} +import { + detectPolygonFacesCollision, + type PolygonFaces, +} from './polygon-faces-collision.js'; /** * Detects a collision between two convex polygon-shaped bodies using the @@ -236,84 +21,24 @@ export function detectPolygonPolygonCollision( const shapeA = bodyA.shape as PolygonShape; const shapeB = bodyB.shape as PolygonShape; - const polygonA: PolygonFaces = { + const facesA: PolygonFaces = { vertices: shapeA.getWorldVertices(bodyA.position, bodyA.angle), normals: shapeA.getWorldNormals(bodyA.angle), }; - const polygonB: PolygonFaces = { + const facesB: PolygonFaces = { vertices: shapeB.getWorldVertices(bodyB.position, bodyB.angle), normals: shapeB.getWorldNormals(bodyB.angle), }; - const axisA = findAxisOfLeastPenetration( - polygonA.vertices, - polygonA.normals, - polygonB.vertices, - ); - - if (axisA.separation > 0) { - return null; - } - - const axisB = findAxisOfLeastPenetration( - polygonB.vertices, - polygonB.normals, - polygonA.vertices, - ); - - if (axisB.separation > 0) { - return null; - } - - const { reference, incident, faceIndex, flip } = selectReferenceFace( - polygonA, - polygonB, - axisA, - axisB, - ); - - const referenceNormal = reference.normals[faceIndex]; - const incidentFaceIndex = findIncidentFaceIndex( - referenceNormal, - incident.normals, - ); - - const incidentV1 = incident.vertices[incidentFaceIndex]; - const incidentV2 = - incident.vertices[(incidentFaceIndex + 1) % incident.vertices.length]; - - const referenceV1 = reference.vertices[faceIndex]; - const referenceV2 = - reference.vertices[(faceIndex + 1) % reference.vertices.length]; - - const clippedPoints = clipIncidentEdge( - incidentV1, - incidentV2, - referenceV1, - referenceV2, - ); + const contact = detectPolygonFacesCollision(facesA, facesB); - if (clippedPoints === null) { + if (contact === null) { return null; } - const { contactPoints, depth } = findContactPoints( - clippedPoints, - referenceNormal, - referenceV1, - ); - - if (contactPoints.length === 0) { - return null; - } - - const normal = flip ? referenceNormal.negate() : referenceNormal; - return { bodyA, bodyB, - normal, - depth, - contactPoints, + ...contact, }; } diff --git a/src/physics/collision/detect-polygon-terrain-collision.test.ts b/src/physics/collision/detect-polygon-terrain-collision.test.ts new file mode 100644 index 00000000..b373c677 --- /dev/null +++ b/src/physics/collision/detect-polygon-terrain-collision.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { detectPolygonTerrainCollision } from './detect-polygon-terrain-collision.js'; +import { RigidBody } from '../rigid-body.js'; +import { PolygonShape, TerrainShape } from '../shapes/index.js'; +import { Vector2 } from '../../math/index.js'; + +function flatTerrainBody(options: { angle?: number } = {}): RigidBody { + return new RigidBody({ + shape: new TerrainShape( + [new Vector2(-5, 0), new Vector2(0, 0), new Vector2(5, 0)], + 2, + ), + position: Vector2.zero, + angle: options.angle ?? 0, + isStatic: true, + }); +} + +describe('detectPolygonTerrainCollision', () => { + it('should return null when the polygon does not overlap the terrain', () => { + const polygonBody = new RigidBody({ + shape: PolygonShape.rectangle(2, 2), + position: new Vector2(0, -10), + }); + + expect( + detectPolygonTerrainCollision(polygonBody, flatTerrainBody()), + ).toBeNull(); + }); + + it('should return null when the polygon is outside the terrain x-range', () => { + const polygonBody = new RigidBody({ + shape: PolygonShape.rectangle(2, 2), + position: new Vector2(20, 0), + }); + + expect( + detectPolygonTerrainCollision(polygonBody, flatTerrainBody()), + ).toBeNull(); + }); + + it('should detect a box resting into the terrain surface', () => { + const polygonBody = new RigidBody({ + shape: PolygonShape.rectangle(2, 2), + position: new Vector2(0, -0.5), + }); + + const manifold = detectPolygonTerrainCollision( + polygonBody, + flatTerrainBody(), + ); + + expect(manifold).not.toBeNull(); + expect(manifold?.bodyA).toBe(polygonBody); + expect(manifold?.normal.x).toBeCloseTo(0); + expect(manifold?.normal.y).toBeCloseTo(1); + expect(manifold?.depth).toBeCloseTo(0.5); + expect(manifold?.contactPoints).toHaveLength(2); + }); + + it('should detect a box spanning the boundary between two segments', () => { + const polygonBody = new RigidBody({ + shape: PolygonShape.rectangle(4, 2), + position: new Vector2(0, -0.5), + }); + + const manifold = detectPolygonTerrainCollision( + polygonBody, + flatTerrainBody(), + ); + + expect(manifold).not.toBeNull(); + expect(manifold?.depth).toBeCloseTo(0.5); + expect(manifold?.contactPoints).toHaveLength(2); + }); + + it('should account for the terrain body rotation', () => { + const angle = Math.PI / 2; + const terrainBody = flatTerrainBody({ angle }); + + // Rotating the flat terrain 90 degrees turns its surface into a wall + // facing in -x; a box pushed 0.5 units into it from the +x side should + // collide with a normal pointing back out along +x. + const polygonBody = new RigidBody({ + shape: PolygonShape.rectangle(2, 2), + position: new Vector2(0.5, 0), + }); + + const manifold = detectPolygonTerrainCollision(polygonBody, terrainBody); + + expect(manifold).not.toBeNull(); + expect(manifold?.normal.x).toBeCloseTo(-1); + expect(manifold?.normal.y).toBeCloseTo(0); + expect(manifold?.depth).toBeCloseTo(0.5); + }); +}); diff --git a/src/physics/collision/detect-polygon-terrain-collision.ts b/src/physics/collision/detect-polygon-terrain-collision.ts new file mode 100644 index 00000000..7b8e42a6 --- /dev/null +++ b/src/physics/collision/detect-polygon-terrain-collision.ts @@ -0,0 +1,92 @@ +import type { Vector2 } from '../../math/index.js'; +import type { RigidBody } from '../rigid-body.js'; +import type { PolygonShape, TerrainShape } from '../shapes/index.js'; +import type { CollisionManifold } from './collision-manifold.js'; +import { + detectPolygonFacesCollision, + type PolygonFaces, + type PolygonFacesContact, +} from './polygon-faces-collision.js'; + +function localXRange( + worldVertices: readonly Vector2[], + terrainBody: RigidBody, +): { minX: number; maxX: number } { + let minX = Infinity; + let maxX = -Infinity; + + for (const vertex of worldVertices) { + const localX = vertex + .subtract(terrainBody.position) + .rotate(-terrainBody.angle).x; + + minX = Math.min(minX, localX); + maxX = Math.max(maxX, localX); + } + + return { minX, maxX }; +} + +/** + * Detects a collision between a convex polygon-shaped body and a + * terrain-shaped body, by running the same polygon-vs-polygon narrow phase + * used for {@link detectPolygonPolygonCollision} against each of the + * terrain's segments that overlap the polygon's local x-range, keeping the + * deepest resulting contact. + * @param polygonBody - The body with a {@link PolygonShape}. Becomes + * `bodyA` of the returned manifold. + * @param terrainBody - The body with a {@link TerrainShape}. Becomes + * `bodyB` of the returned manifold. + * @returns A {@link CollisionManifold} if the shapes overlap, otherwise + * `null`. + */ +export function detectPolygonTerrainCollision( + polygonBody: RigidBody, + terrainBody: RigidBody, +): CollisionManifold | null { + const polygonShape = polygonBody.shape as PolygonShape; + const terrainShape = terrainBody.shape as TerrainShape; + + const polygonFaces: PolygonFaces = { + vertices: polygonShape.getWorldVertices( + polygonBody.position, + polygonBody.angle, + ), + normals: polygonShape.getWorldNormals(polygonBody.angle), + }; + + const { minX, maxX } = localXRange(polygonFaces.vertices, terrainBody); + + let best: PolygonFacesContact | null = null; + + for (const segment of terrainShape.segments) { + if (maxX < segment.minX || minX > segment.maxX) { + continue; + } + + const segmentFaces: PolygonFaces = { + vertices: segment.vertices.map((vertex) => + vertex.rotate(terrainBody.angle).add(terrainBody.position), + ), + normals: segment.normals.map((normal) => + normal.rotate(terrainBody.angle), + ), + }; + + const contact = detectPolygonFacesCollision(polygonFaces, segmentFaces); + + if (contact !== null && (best === null || contact.depth > best.depth)) { + best = contact; + } + } + + if (best === null) { + return null; + } + + return { + bodyA: polygonBody, + bodyB: terrainBody, + ...best, + }; +} diff --git a/src/physics/collision/index.ts b/src/physics/collision/index.ts index 99bd3f6c..325f4c36 100644 --- a/src/physics/collision/index.ts +++ b/src/physics/collision/index.ts @@ -1,6 +1,10 @@ +export * from './circle-polygon-contact.js'; export * from './collision-manifold.js'; export * from './detect-circle-circle-collision.js'; export * from './detect-circle-polygon-collision.js'; +export * from './detect-circle-terrain-collision.js'; export * from './detect-collision.js'; export * from './detect-polygon-polygon-collision.js'; +export * from './detect-polygon-terrain-collision.js'; +export * from './polygon-faces-collision.js'; export * from './resolve-collision.js'; diff --git a/src/physics/collision/polygon-faces-collision.ts b/src/physics/collision/polygon-faces-collision.ts new file mode 100644 index 00000000..7a7ba7ed --- /dev/null +++ b/src/physics/collision/polygon-faces-collision.ts @@ -0,0 +1,330 @@ +import { Vector2 } from '../../math/index.js'; + +const RELATIVE_TOLERANCE = 0.95; +const ABSOLUTE_TOLERANCE = 0.01; + +/** + * The world-space vertices and outward-facing edge normals of a convex + * polygon, as consumed by {@link detectPolygonFacesCollision}. + */ +export interface PolygonFaces { + vertices: Vector2[]; + normals: Vector2[]; +} + +/** + * The result of a successful {@link detectPolygonFacesCollision} check, + * everything a {@link CollisionManifold} needs besides the two bodies + * involved. + */ +export interface PolygonFacesContact { + /** + * The contact normal, in world space, pointing from `facesA` toward + * `facesB`. + */ + normal: Vector2; + + /** + * The penetration depth of the contact. + */ + depth: number; + + /** + * The world-space contact points (one or two points). + */ + contactPoints: Vector2[]; +} + +interface Axis { + separation: number; + faceIndex: number; +} + +interface ReferenceIncidentFaces { + reference: PolygonFaces; + incident: PolygonFaces; + faceIndex: number; + flip: boolean; +} + +/** + * Returns the vertex that is furthest along the given direction. + */ +function getSupportPoint(vertices: Vector2[], direction: Vector2): Vector2 { + let bestVertex = vertices[0]; + let bestProjection = direction.dot(bestVertex); + + for (let i = 1; i < vertices.length; i++) { + const projection = direction.dot(vertices[i]); + + if (projection > bestProjection) { + bestVertex = vertices[i]; + bestProjection = projection; + } + } + + return bestVertex; +} + +/** + * Finds the face of the first polygon whose normal yields the largest + * separation from the second polygon. A positive separation means the + * polygons do not overlap along that axis. + */ +function findAxisOfLeastPenetration( + ownVertices: Vector2[], + ownNormals: Vector2[], + otherVertices: Vector2[], +): Axis { + let bestSeparation = -Infinity; + let bestFaceIndex = 0; + + for (let i = 0; i < ownNormals.length; i++) { + const normal = ownNormals[i]; + const supportPoint = getSupportPoint(otherVertices, normal.negate()); + const separation = normal.dot(supportPoint.subtract(ownVertices[i])); + + if (separation > bestSeparation) { + bestSeparation = separation; + bestFaceIndex = i; + } + } + + return { separation: bestSeparation, faceIndex: bestFaceIndex }; +} + +/** + * Picks which polygon's face of least penetration acts as the reference + * face, biasing toward the first polygon to avoid face-flip jitter when the + * separations are nearly equal. + */ +function selectReferenceFace( + polygonA: PolygonFaces, + polygonB: PolygonFaces, + axisA: Axis, + axisB: Axis, +): ReferenceIncidentFaces { + const flip = + axisB.separation > + RELATIVE_TOLERANCE * axisA.separation + ABSOLUTE_TOLERANCE; + + if (flip) { + return { + reference: polygonB, + incident: polygonA, + faceIndex: axisB.faceIndex, + flip, + }; + } + + return { + reference: polygonA, + incident: polygonB, + faceIndex: axisA.faceIndex, + flip, + }; +} + +/** + * Finds the incident polygon's face whose normal is most anti-parallel to + * the reference face's normal. + */ +function findIncidentFaceIndex( + referenceNormal: Vector2, + incidentNormals: Vector2[], +): number { + let incidentFaceIndex = 0; + let minDot = Infinity; + + for (let i = 0; i < incidentNormals.length; i++) { + const dot = referenceNormal.dot(incidentNormals[i]); + + if (dot < minDot) { + minDot = dot; + incidentFaceIndex = i; + } + } + + return incidentFaceIndex; +} + +/** + * Clips the segment `v1`-`v2` against the half-plane + * `normal . point <= offset`, returning the points that lie within it + * (including any interpolated intersection point). + */ +function clip( + v1: Vector2, + v2: Vector2, + normal: Vector2, + offset: number, +): Vector2[] { + const result: Vector2[] = []; + + const distance1 = normal.dot(v1) - offset; + const distance2 = normal.dot(v2) - offset; + + if (distance1 <= 0) { + result.push(v1); + } + + if (distance2 <= 0) { + result.push(v2); + } + + if (distance1 * distance2 < 0) { + const t = distance1 / (distance1 - distance2); + + result.push(v1.add(v2.subtract(v1).multiply(t))); + } + + return result; +} + +/** + * Clips the incident edge against the two side planes of the reference + * face, returning `null` if the incident edge lies entirely outside either + * side plane. + */ +function clipIncidentEdge( + incidentV1: Vector2, + incidentV2: Vector2, + referenceV1: Vector2, + referenceV2: Vector2, +): Vector2[] | null { + const tangent = referenceV2.subtract(referenceV1).normalize(); + const negativeSideOffset = -tangent.dot(referenceV1); + const positiveSideOffset = tangent.dot(referenceV2); + + let clippedPoints = clip( + incidentV1, + incidentV2, + tangent.negate(), + negativeSideOffset, + ); + + if (clippedPoints.length < 2) { + return null; + } + + clippedPoints = clip( + clippedPoints[0], + clippedPoints[1], + tangent, + positiveSideOffset, + ); + + if (clippedPoints.length < 2) { + return null; + } + + return clippedPoints; +} + +/** + * Discards clipped points that are not penetrating the reference face and + * computes the penetration depth of the remaining contact points. + */ +function findContactPoints( + clippedPoints: Vector2[], + referenceNormal: Vector2, + referenceV1: Vector2, +): { contactPoints: Vector2[]; depth: number } { + const contactPoints: Vector2[] = []; + let depth = 0; + + for (const point of clippedPoints) { + const separation = referenceNormal.dot(point.subtract(referenceV1)); + + if (separation <= 0) { + contactPoints.push(point); + depth = Math.max(depth, -separation); + } + } + + return { contactPoints, depth }; +} + +/** + * Detects a collision between two convex polygons, given as world-space + * {@link PolygonFaces}, using the separating axis theorem with + * reference/incident face clipping. + * @param facesA - The first polygon's world-space vertices and normals. + * @param facesB - The second polygon's world-space vertices and normals. + * @returns A {@link PolygonFacesContact} if the polygons overlap, otherwise + * `null`. + */ +export function detectPolygonFacesCollision( + facesA: PolygonFaces, + facesB: PolygonFaces, +): PolygonFacesContact | null { + const axisA = findAxisOfLeastPenetration( + facesA.vertices, + facesA.normals, + facesB.vertices, + ); + + if (axisA.separation > 0) { + return null; + } + + const axisB = findAxisOfLeastPenetration( + facesB.vertices, + facesB.normals, + facesA.vertices, + ); + + if (axisB.separation > 0) { + return null; + } + + const { reference, incident, faceIndex, flip } = selectReferenceFace( + facesA, + facesB, + axisA, + axisB, + ); + + const referenceNormal = reference.normals[faceIndex]; + const incidentFaceIndex = findIncidentFaceIndex( + referenceNormal, + incident.normals, + ); + + const incidentV1 = incident.vertices[incidentFaceIndex]; + const incidentV2 = + incident.vertices[(incidentFaceIndex + 1) % incident.vertices.length]; + + const referenceV1 = reference.vertices[faceIndex]; + const referenceV2 = + reference.vertices[(faceIndex + 1) % reference.vertices.length]; + + const clippedPoints = clipIncidentEdge( + incidentV1, + incidentV2, + referenceV1, + referenceV2, + ); + + if (clippedPoints === null) { + return null; + } + + const { contactPoints, depth } = findContactPoints( + clippedPoints, + referenceNormal, + referenceV1, + ); + + if (contactPoints.length === 0) { + return null; + } + + const normal = flip ? referenceNormal.negate() : referenceNormal; + + return { + normal, + depth, + contactPoints, + }; +} diff --git a/src/physics/physics-world.test.ts b/src/physics/physics-world.test.ts index 061b5c7f..aed15b71 100644 --- a/src/physics/physics-world.test.ts +++ b/src/physics/physics-world.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { PrismaticJoint, RevoluteJoint } from './joints/index.js'; import { PhysicsWorld } from './physics-world.js'; import { RigidBody } from './rigid-body.js'; -import { CircleShape, PolygonShape } from './shapes/index.js'; +import { CircleShape, PolygonShape, TerrainShape } from './shapes/index.js'; import { Vector2 } from '../math/index.js'; describe('PhysicsWorld', () => { @@ -122,6 +122,33 @@ describe('PhysicsWorld', () => { expect(circleBody.position.y).toBeCloseTo(3.0419, 3); expect(circleBody.velocity.equals(Vector2.zero)).toBe(true); }); + + it('should settle a dynamic circle falling onto a static terrain body', () => { + const world = new PhysicsWorld({ gravity: new Vector2(0, 10) }); + const terrainBody = new RigidBody({ + shape: new TerrainShape( + [new Vector2(-10, 0), new Vector2(0, 0), new Vector2(10, 0)], + 5, + ), + position: Vector2.zero, + isStatic: true, + }); + const circleBody = new RigidBody({ + shape: new CircleShape(1), + position: new Vector2(0, -3), + restitution: 0, + }); + + world.addBody(circleBody); + world.addBody(terrainBody); + + for (let i = 0; i < 120; i++) { + world.step(1 / 60); + } + + expect(circleBody.position.y).toBeCloseTo(-1, 1); + expect(circleBody.velocity.magnitude()).toBeLessThan(0.1); + }); }); describe('isSensor', () => { diff --git a/src/physics/shapes/index.ts b/src/physics/shapes/index.ts index ae7c67c4..9c42f04f 100644 --- a/src/physics/shapes/index.ts +++ b/src/physics/shapes/index.ts @@ -1,3 +1,5 @@ export * from './circle-shape.js'; +export * from './polygon-math.js'; export * from './polygon-shape.js'; export * from './shape.js'; +export * from './terrain-shape.js'; diff --git a/src/physics/shapes/polygon-math.ts b/src/physics/shapes/polygon-math.ts new file mode 100644 index 00000000..8f397164 --- /dev/null +++ b/src/physics/shapes/polygon-math.ts @@ -0,0 +1,98 @@ +import { Vector2 } from '../../math/index.js'; + +/** + * Calculates the signed area of a polygon (twice the actual area). Positive + * for a polygon whose vertices are wound in this engine's canonical + * "outward-normal" order (see {@link PolygonShape}'s constructor), negative + * for the reverse winding. + * @param vertices - The polygon's vertices, in order. + * @returns The signed area of the polygon. + */ +export function calculateSignedArea(vertices: readonly Vector2[]): number { + let signedArea = 0; + + for (let i = 0; i < vertices.length; i++) { + const current = vertices[i]; + const next = vertices[(i + 1) % vertices.length]; + + signedArea += current.cross(next); + } + + return signedArea; +} + +/** + * Calculates the centroid (center of mass, for a uniform-density polygon) + * of a simple polygon. + * @param vertices - The polygon's vertices, in order. + * @returns The centroid of the polygon. + */ +export function calculateCentroid(vertices: readonly Vector2[]): Vector2 { + let centroidX = 0; + let centroidY = 0; + let signedArea = 0; + + for (let i = 0; i < vertices.length; i++) { + const current = vertices[i]; + const next = vertices[(i + 1) % vertices.length]; + const cross = current.cross(next); + + signedArea += cross; + centroidX += (current.x + next.x) * cross; + centroidY += (current.y + next.y) * cross; + } + + const factor = 1 / (3 * signedArea); + + return new Vector2(centroidX * factor, centroidY * factor); +} + +/** + * Calculates the outward-facing edge normal for each edge of a polygon + * (the edge from vertex `i` to vertex `i + 1`), assuming vertices are + * wound in this engine's canonical order (see {@link calculateSignedArea}). + * @param vertices - The polygon's vertices, in order. + * @returns The polygon's edge normals, in the same order as `vertices`. + */ +export function calculateNormals(vertices: readonly Vector2[]): Vector2[] { + const normals: Vector2[] = []; + + for (let i = 0; i < vertices.length; i++) { + const current = vertices[i]; + const next = vertices[(i + 1) % vertices.length]; + const edge = next.subtract(current); + + normals.push(edge.perpendicular().normalize()); + } + + return normals; +} + +/** + * Calculates the moment of inertia of a polygon for a given mass, about its + * own center of mass. + * @param mass - The mass of the body the polygon belongs to. + * @param verticesAboutCentroid - The polygon's vertices, in order, relative + * to its own centroid (i.e. already translated so their average, weighted + * by {@link calculateCentroid}, is the origin). + * @returns The moment of inertia of the polygon. + */ +export function calculatePolygonMomentOfInertia( + mass: number, + verticesAboutCentroid: readonly Vector2[], +): number { + let numerator = 0; + let denominator = 0; + + for (let i = 0; i < verticesAboutCentroid.length; i++) { + const current = verticesAboutCentroid[i]; + const next = verticesAboutCentroid[(i + 1) % verticesAboutCentroid.length]; + const cross = Math.abs(current.cross(next)); + + numerator += + cross * (current.dot(current) + current.dot(next) + next.dot(next)); + denominator += cross; + } + + return (mass / 3) * (numerator / denominator); +} diff --git a/src/physics/shapes/polygon-shape.ts b/src/physics/shapes/polygon-shape.ts index 7f4f0d8b..dc11aaa0 100644 --- a/src/physics/shapes/polygon-shape.ts +++ b/src/physics/shapes/polygon-shape.ts @@ -1,4 +1,10 @@ import { Vector2 } from '../../math/index.js'; +import { + calculateCentroid, + calculateNormals, + calculatePolygonMomentOfInertia, + calculateSignedArea, +} from './polygon-math.js'; import type { ShapeBase } from './shape.js'; const EPSILON = 1e-9; @@ -52,7 +58,7 @@ export class PolygonShape implements ShapeBase { } let orderedVertices = [...vertices]; - const signedArea = PolygonShape._signedArea(orderedVertices); + const signedArea = calculateSignedArea(orderedVertices); if (Math.abs(signedArea) < EPSILON) { throw new Error( @@ -66,10 +72,10 @@ export class PolygonShape implements ShapeBase { PolygonShape._validateConvexity(orderedVertices); - const centroid = PolygonShape._calculateCentroid(orderedVertices); + const centroid = calculateCentroid(orderedVertices); this.vertices = orderedVertices.map((vertex) => vertex.subtract(centroid)); - this.normals = PolygonShape._calculateNormals(this.vertices); + this.normals = calculateNormals(this.vertices); this._worldVerticesCache = null; this._worldNormalsCache = null; } @@ -92,19 +98,6 @@ export class PolygonShape implements ShapeBase { ]); } - private static _signedArea(vertices: readonly Vector2[]): number { - let signedArea = 0; - - for (let i = 0; i < vertices.length; i++) { - const current = vertices[i]; - const next = vertices[(i + 1) % vertices.length]; - - signedArea += current.cross(next); - } - - return signedArea; - } - private static _validateConvexity(vertices: readonly Vector2[]): void { const vertexCount = vertices.length; @@ -122,46 +115,12 @@ export class PolygonShape implements ShapeBase { } } - private static _calculateCentroid(vertices: readonly Vector2[]): Vector2 { - let centroidX = 0; - let centroidY = 0; - let signedArea = 0; - - for (let i = 0; i < vertices.length; i++) { - const current = vertices[i]; - const next = vertices[(i + 1) % vertices.length]; - const cross = current.cross(next); - - signedArea += cross; - centroidX += (current.x + next.x) * cross; - centroidY += (current.y + next.y) * cross; - } - - const factor = 1 / (3 * signedArea); - - return new Vector2(centroidX * factor, centroidY * factor); - } - - private static _calculateNormals(vertices: readonly Vector2[]): Vector2[] { - const normals: Vector2[] = []; - - for (let i = 0; i < vertices.length; i++) { - const current = vertices[i]; - const next = vertices[(i + 1) % vertices.length]; - const edge = next.subtract(current); - - normals.push(edge.perpendicular().normalize()); - } - - return normals; - } - /** * Calculates the area of the polygon. * @returns The area of the polygon. */ public getArea(): number { - return Math.abs(PolygonShape._signedArea(this.vertices)) / 2; + return Math.abs(calculateSignedArea(this.vertices)) / 2; } /** @@ -170,20 +129,7 @@ export class PolygonShape implements ShapeBase { * @returns The moment of inertia of the polygon. */ public getMomentOfInertia(mass: number): number { - let numerator = 0; - let denominator = 0; - - for (let i = 0; i < this.vertices.length; i++) { - const current = this.vertices[i]; - const next = this.vertices[(i + 1) % this.vertices.length]; - const cross = Math.abs(current.cross(next)); - - numerator += - cross * (current.dot(current) + current.dot(next) + next.dot(next)); - denominator += cross; - } - - return (mass / 3) * (numerator / denominator); + return calculatePolygonMomentOfInertia(mass, this.vertices); } /** diff --git a/src/physics/shapes/shape.ts b/src/physics/shapes/shape.ts index b65300b3..27350cc7 100644 --- a/src/physics/shapes/shape.ts +++ b/src/physics/shapes/shape.ts @@ -1,10 +1,11 @@ import type { CircleShape } from './circle-shape.js'; import type { PolygonShape } from './polygon-shape.js'; +import type { TerrainShape } from './terrain-shape.js'; /** * The kind of geometry a {@link Shape} represents. */ -export type ShapeType = 'circle' | 'polygon'; +export type ShapeType = 'circle' | 'polygon' | 'terrain'; /** * Common contract implemented by all collision shapes. @@ -38,6 +39,7 @@ export interface ShapeBase { } /** - * A collision shape, either a {@link CircleShape} or a {@link PolygonShape}. + * A collision shape: a {@link CircleShape}, a {@link PolygonShape}, or a + * {@link TerrainShape}. */ -export type Shape = CircleShape | PolygonShape; +export type Shape = CircleShape | PolygonShape | TerrainShape; diff --git a/src/physics/shapes/terrain-shape.test.ts b/src/physics/shapes/terrain-shape.test.ts new file mode 100644 index 00000000..63f218a0 --- /dev/null +++ b/src/physics/shapes/terrain-shape.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import { TerrainShape } from './terrain-shape.js'; +import { Vector2 } from '../../math/index.js'; + +describe('TerrainShape', () => { + describe('constructor', () => { + it('should throw an error if fewer than 2 points are provided', () => { + expect(() => new TerrainShape([new Vector2(0, 0)], 10)).toThrow(); + }); + + it('should throw an error if points are not ordered by strictly increasing x', () => { + expect( + () => + new TerrainShape( + [new Vector2(0, 0), new Vector2(0, 10), new Vector2(2, 0)], + 10, + ), + ).toThrow(); + }); + + it('should throw an error if depth is not positive', () => { + expect( + () => new TerrainShape([new Vector2(0, 0), new Vector2(1, 0)], 0), + ).toThrow(); + }); + + it('should not re-center points around a centroid', () => { + const terrain = new TerrainShape( + [new Vector2(0, 0), new Vector2(10, 0)], + 10, + ); + + expect(terrain.points[0].x).toBeCloseTo(0); + expect(terrain.points[0].y).toBeCloseTo(0); + expect(terrain.points[1].x).toBeCloseTo(10); + expect(terrain.points[1].y).toBeCloseTo(0); + }); + + it('should place the flat bottom edge depth units below the lowest point', () => { + const terrain = new TerrainShape( + [new Vector2(0, -20), new Vector2(10, 5), new Vector2(20, 0)], + 10, + ); + + expect(terrain.bottomY).toBeCloseTo(15); + }); + }); + + describe('segments', () => { + it('should create one convex quad segment per consecutive pair of points', () => { + const terrain = new TerrainShape( + [new Vector2(0, 0), new Vector2(10, 0), new Vector2(20, -5)], + 10, + ); + + expect(terrain.segments).toHaveLength(2); + expect(terrain.segments[0].minX).toBeCloseTo(0); + expect(terrain.segments[0].maxX).toBeCloseTo(10); + expect(terrain.segments[1].minX).toBeCloseTo(10); + expect(terrain.segments[1].maxX).toBeCloseTo(20); + }); + + it('should produce an upward-facing normal for a flat segment', () => { + const terrain = new TerrainShape( + [new Vector2(0, 0), new Vector2(10, 0)], + 10, + ); + + const [topNormal] = terrain.segments[0].normals; + + expect(topNormal.x).toBeCloseTo(0); + expect(topNormal.y).toBeCloseTo(-1); + }); + }); + + describe('getArea', () => { + it('should calculate the area of a flat terrain strip', () => { + const terrain = new TerrainShape( + [new Vector2(0, 0), new Vector2(10, 0)], + 5, + ); + + expect(terrain.getArea()).toBeCloseTo(50); + }); + + it('should calculate the area of a sloped terrain strip', () => { + // A single downward slope closed off by a flat bottom at y = 10 forms + // a trapezoid with parallel verticals of length 10 and 5, width 10. + const terrain = new TerrainShape( + [new Vector2(0, 0), new Vector2(10, 5)], + 5, + ); + + expect(terrain.getArea()).toBeCloseTo(((10 + 5) / 2) * 10); + }); + }); + + describe('getMomentOfInertia', () => { + it('should calculate the moment of inertia of a flat terrain strip like an equivalent rectangle', () => { + const width = 10; + const height = 5; + const mass = 20; + + const terrain = new TerrainShape( + [new Vector2(0, 0), new Vector2(width, 0)], + height, + ); + + // Matches PolygonShape.getMomentOfInertia's convention for a + // rectangle about its centroid (see polygon-shape.test.ts's + // equivalent square case). + const expected = (mass / 6) * (width * width + height * height); + + expect(terrain.getMomentOfInertia(mass)).toBeCloseTo(expected); + }); + }); + + describe('getBoundingRadius', () => { + it('should return the distance from the local origin to the furthest vertex', () => { + const terrain = new TerrainShape( + [new Vector2(0, 0), new Vector2(10, 0)], + 5, + ); + + // Furthest vertex from the local origin (0,0) is the bottom-right + // corner at (10, 5). + expect(terrain.getBoundingRadius()).toBeCloseTo( + Math.sqrt(10 * 10 + 5 * 5), + ); + }); + }); +}); diff --git a/src/physics/shapes/terrain-shape.ts b/src/physics/shapes/terrain-shape.ts new file mode 100644 index 00000000..c3b21981 --- /dev/null +++ b/src/physics/shapes/terrain-shape.ts @@ -0,0 +1,204 @@ +import { Vector2 } from '../../math/index.js'; +import { + calculateCentroid, + calculateNormals, + calculatePolygonMomentOfInertia, + calculateSignedArea, +} from './polygon-math.js'; +import type { ShapeBase } from './shape.js'; + +/** + * A single span of ground between two consecutive {@link TerrainShape} + * surface points, closed off into a convex quadrilateral by a flat bottom + * edge. `vertices`/`normals` follow this engine's canonical winding (see + * `PolygonShape`'s constructor): top-left, top-right, bottom-right, + * bottom-left, with outward-facing normals in the same order. All fields + * are in the terrain's local space. + */ +export interface TerrainSegment { + /** + * The segment's four vertices, in local space: the two surface points + * followed by their corresponding points on the terrain's flat bottom + * edge. + */ + vertices: readonly Vector2[]; + + /** + * The segment's four outward-facing edge normals, in local space, + * corresponding to the edges between consecutive `vertices`. + */ + normals: readonly Vector2[]; + + /** + * The lesser of the segment's two surface points' x-coordinates. Used to + * cheaply filter candidate segments before running narrow-phase collision + * checks against them. + */ + minX: number; + + /** + * The greater of the segment's two surface points' x-coordinates. + */ + maxX: number; +} + +const EPSILON = 1e-9; + +/** + * A static, non-convex 2D ground collider defined by a heightmap: a chain + * of surface points, ordered left to right, closed off into a solid slab by + * a flat bottom edge `depth` units below the lowest surface point. + * + * Unlike {@link PolygonShape}, `TerrainShape` does not re-center its + * vertices around their centroid - `points` are used exactly as authored, + * in the shape's own local space, with the owning `RigidBody`'s `position` + * acting as a simple translation offset (typically `Vector2.zero`, with the + * terrain authored directly in world coordinates). This matches how + * heightmap terrain is normally authored, and keeps a terrain's points + * meaningful on their own without also tracking a separately-computed + * centroid offset. + * + * Narrow-phase collision against a `TerrainShape` (see + * `detectCircleTerrainCollision`/`detectPolygonTerrainCollision`) is + * resolved per-segment: each pair of consecutive surface points, plus the + * flat bottom edge, forms a convex quadrilateral ({@link segments}), and the + * existing circle/polygon narrow-phase routines run against whichever + * segments overlap the other body's local x-range. + * + * Terrain is intended to always be static (see `RigidBodyOptions.isStatic`) + * - a heightmap has no meaningful mass distribution to simulate as a moving + * body. `getArea`/`getMomentOfInertia` are still implemented correctly (for + * a hypothetical dynamic use), but are never called for a static body. + */ +export class TerrainShape implements ShapeBase { + public readonly type = 'terrain'; + + public readonly points: readonly Vector2[]; + + public readonly depth: number; + + /** + * The flat bottom edge's y-coordinate, in local space: `depth` units + * below the lowest of `points`. + */ + public readonly bottomY: number; + + /** + * The convex quadrilaterals narrow-phase collision detection tests + * against, one per consecutive pair of `points`. + */ + public readonly segments: readonly TerrainSegment[]; + + /** + * Creates a new TerrainShape instance. + * @param points - The local-space surface points of the terrain, ordered + * by strictly increasing x. Must contain at least 2 points. + * @param depth - The thickness of the terrain slab below its lowest + * point. Must be positive. + * @throws An error if fewer than 2 points are provided, the points are + * not ordered by strictly increasing x, or `depth` is not positive. + */ + constructor(points: readonly Vector2[], depth: number) { + if (points.length < 2) { + throw new Error( + `TerrainShape requires at least 2 points, received ${points.length}.`, + ); + } + + for (let i = 0; i < points.length - 1; i++) { + if (points[i + 1].x <= points[i].x + EPSILON) { + throw new Error( + 'TerrainShape points must be ordered by strictly increasing x.', + ); + } + } + + if (depth <= 0) { + throw new Error( + `TerrainShape depth must be positive, received "${depth}".`, + ); + } + + this.points = points.map((point) => point.clone()); + this.depth = depth; + this.bottomY = Math.max(...this.points.map((point) => point.y)) + depth; + this.segments = this._buildSegments(); + } + + /** + * Calculates the area of the terrain's full silhouette (its surface + * points, closed off by the flat bottom edge). + * @returns The area of the terrain. + */ + public getArea(): number { + return Math.abs(calculateSignedArea(this._silhouetteVertices())) / 2; + } + + /** + * Calculates the moment of inertia of the terrain's full silhouette for a + * given mass, about its own center of mass. + * @param mass - The mass of the body the shape belongs to. + * @returns The moment of inertia of the terrain. + */ + public getMomentOfInertia(mass: number): number { + const silhouette = this._silhouetteVertices(); + const centroid = calculateCentroid(silhouette); + const verticesAboutCentroid = silhouette.map((vertex) => + vertex.subtract(centroid), + ); + + return calculatePolygonMomentOfInertia(mass, verticesAboutCentroid); + } + + /** + * Calculates the radius of the smallest circle, centered on the shape's + * local origin (not its centroid - see the class documentation), that + * fully contains the terrain. + * @returns The bounding radius of the terrain. + */ + public getBoundingRadius(): number { + let maxRadiusSquared = 0; + + for (const vertex of this._silhouetteVertices()) { + maxRadiusSquared = Math.max(maxRadiusSquared, vertex.magnitudeSquared()); + } + + return Math.sqrt(maxRadiusSquared); + } + + private _silhouetteVertices(): Vector2[] { + const first = this.points[0]; + const last = this.points[this.points.length - 1]; + + return [ + ...this.points, + new Vector2(last.x, this.bottomY), + new Vector2(first.x, this.bottomY), + ]; + } + + private _buildSegments(): TerrainSegment[] { + const segments: TerrainSegment[] = []; + + for (let i = 0; i < this.points.length - 1; i++) { + const surfaceLeft = this.points[i]; + const surfaceRight = this.points[i + 1]; + + const vertices: Vector2[] = [ + surfaceLeft, + surfaceRight, + new Vector2(surfaceRight.x, this.bottomY), + new Vector2(surfaceLeft.x, this.bottomY), + ]; + + segments.push({ + vertices, + normals: calculateNormals(vertices), + minX: surfaceLeft.x, + maxX: surfaceRight.x, + }); + } + + return segments; + } +} diff --git a/src/rendering/index.ts b/src/rendering/index.ts index e69e4bc3..a06a5c8d 100644 --- a/src/rendering/index.ts +++ b/src/rendering/index.ts @@ -14,3 +14,4 @@ export * from './render-context.js'; export * from './render-target.js'; export * from './ping-pong-target.js'; export * from './fullscreen-pass.js'; +export * from './terrain/index.js'; diff --git a/src/rendering/shaders/utils/create-texture-from-image.test.ts b/src/rendering/shaders/utils/create-texture-from-image.test.ts index 648677a3..752749ae 100644 --- a/src/rendering/shaders/utils/create-texture-from-image.test.ts +++ b/src/rendering/shaders/utils/create-texture-from-image.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { createTextureFromImage } from './create-texture-from-image'; @@ -6,12 +7,16 @@ describe('createTextureFromImage', () => { let image: TexImageSource; beforeEach(() => { - // Create a mock WebGL2RenderingContext + // Create a mock WebGL2RenderingContext. CLAMP_TO_EDGE and REPEAT are + // given distinct dummy values (rather than left undefined) so tests can + // actually tell which one a call used. gl = { createTexture: vi.fn(), bindTexture: vi.fn(), texParameteri: vi.fn(), texImage2D: vi.fn(), + CLAMP_TO_EDGE: 'CLAMP_TO_EDGE', + REPEAT: 'REPEAT', } as unknown as WebGL2RenderingContext; // Create a mock image @@ -56,4 +61,33 @@ describe('createTextureFromImage', () => { ); expect(result).toBe(texture); }); + + it('should wrap with REPEAT on both axes when tile is true', () => { + (gl.createTexture as Mock).mockReturnValue({}); + + createTextureFromImage(gl, image, false, true); + + expect(gl.texParameteri).toHaveBeenCalledWith( + gl.TEXTURE_2D, + gl.TEXTURE_WRAP_S, + gl.REPEAT, + ); + expect(gl.texParameteri).toHaveBeenCalledWith( + gl.TEXTURE_2D, + gl.TEXTURE_WRAP_T, + gl.REPEAT, + ); + }); + + it('should default to CLAMP_TO_EDGE wrapping when tile is omitted', () => { + (gl.createTexture as Mock).mockReturnValue({}); + + createTextureFromImage(gl, image); + + expect(gl.texParameteri).toHaveBeenCalledWith( + gl.TEXTURE_2D, + gl.TEXTURE_WRAP_S, + gl.CLAMP_TO_EDGE, + ); + }); }); diff --git a/src/rendering/shaders/utils/create-texture-from-image.ts b/src/rendering/shaders/utils/create-texture-from-image.ts index 07b98d46..5c872312 100644 --- a/src/rendering/shaders/utils/create-texture-from-image.ts +++ b/src/rendering/shaders/utils/create-texture-from-image.ts @@ -3,19 +3,28 @@ * * @param gl - The WebGL2 rendering context. * @param image - The image source to create the texture from. + * @param pixelated - Samples with nearest-neighbor filtering for crisp, + * blocky scaling, appropriate for pixel-art assets. Defaults to `false`. + * @param tile - Wraps with `REPEAT` on both axes instead of `CLAMP_TO_EDGE`, + * for a texture meant to tile across a surface rather than a sprite frame + * (which must never bleed into a neighboring frame in the same atlas). + * Defaults to `false`. * @returns The created WebGL texture. */ export const createTextureFromImage = ( gl: WebGL2RenderingContext, image: TexImageSource, pixelated: boolean = false, + tile: boolean = false, ): WebGLTexture => { const texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + const wrap = tile ? gl.REPEAT : gl.CLAMP_TO_EDGE; + + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap); if (pixelated) { // Use nearest filtering for crisp, pixel-art scaling diff --git a/src/rendering/shaders/utils/shared-placeholder-texture.test.ts b/src/rendering/shaders/utils/shared-placeholder-texture.test.ts index 02635a9a..2400080f 100644 --- a/src/rendering/shaders/utils/shared-placeholder-texture.test.ts +++ b/src/rendering/shaders/utils/shared-placeholder-texture.test.ts @@ -1,19 +1,26 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getSharedBlackTexture } from './shared-placeholder-texture'; +import { + getSharedBlackTexture, + getSharedWhiteTexture, +} from './shared-placeholder-texture'; + +function createMockGl(): WebGL2RenderingContext { + return { + createTexture: vi.fn().mockImplementation(() => ({}) as WebGLTexture), + bindTexture: vi.fn(), + texImage2D: vi.fn(), + TEXTURE_2D: 'TEXTURE_2D', + RGBA: 'RGBA', + UNSIGNED_BYTE: 'UNSIGNED_BYTE', + } as unknown as WebGL2RenderingContext; +} describe('getSharedBlackTexture', () => { let gl: WebGL2RenderingContext; beforeEach(() => { - gl = { - createTexture: vi.fn().mockImplementation(() => ({}) as WebGLTexture), - bindTexture: vi.fn(), - texImage2D: vi.fn(), - TEXTURE_2D: 'TEXTURE_2D', - RGBA: 'RGBA', - UNSIGNED_BYTE: 'UNSIGNED_BYTE', - } as unknown as WebGL2RenderingContext; + gl = createMockGl(); }); it('creates a 1x1 opaque black texture', () => { @@ -41,14 +48,7 @@ describe('getSharedBlackTexture', () => { }); it('creates a distinct texture per context', () => { - const otherGl = { - createTexture: vi.fn().mockImplementation(() => ({}) as WebGLTexture), - bindTexture: vi.fn(), - texImage2D: vi.fn(), - TEXTURE_2D: 'TEXTURE_2D', - RGBA: 'RGBA', - UNSIGNED_BYTE: 'UNSIGNED_BYTE', - } as unknown as WebGL2RenderingContext; + const otherGl = createMockGl(); const first = getSharedBlackTexture(gl); const second = getSharedBlackTexture(otherGl); @@ -56,3 +56,51 @@ describe('getSharedBlackTexture', () => { expect(second).not.toBe(first); }); }); + +describe('getSharedWhiteTexture', () => { + let gl: WebGL2RenderingContext; + + beforeEach(() => { + gl = createMockGl(); + }); + + it('creates a 1x1 opaque white texture', () => { + getSharedWhiteTexture(gl); + + expect(gl.texImage2D).toHaveBeenCalledWith( + gl.TEXTURE_2D, + 0, + gl.RGBA, + 1, + 1, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + new Uint8Array([255, 255, 255, 255]), + ); + }); + + it('returns the same texture on subsequent calls for the same context', () => { + const first = getSharedWhiteTexture(gl); + const second = getSharedWhiteTexture(gl); + + expect(second).toBe(first); + expect(gl.createTexture).toHaveBeenCalledTimes(1); + }); + + it('creates a distinct texture per context', () => { + const otherGl = createMockGl(); + + const first = getSharedWhiteTexture(gl); + const second = getSharedWhiteTexture(otherGl); + + expect(second).not.toBe(first); + }); + + it('is distinct from the shared black texture for the same context', () => { + const black = getSharedBlackTexture(gl); + const white = getSharedWhiteTexture(gl); + + expect(white).not.toBe(black); + }); +}); diff --git a/src/rendering/shaders/utils/shared-placeholder-texture.ts b/src/rendering/shaders/utils/shared-placeholder-texture.ts index d0123523..875cd9dc 100644 --- a/src/rendering/shaders/utils/shared-placeholder-texture.ts +++ b/src/rendering/shaders/utils/shared-placeholder-texture.ts @@ -1,26 +1,26 @@ -// A single 1x1 opaque black texture, reused by every consumer across a given -// `WebGL2RenderingContext` that needs to bind "nothing" to a sampler2D -// uniform without a shader branch (for example a sprite with no emissive -// map): allocating a fresh 1x1 texture per consumer would be wasteful, and -// none of them are ever freed since nothing owns one long enough to dispose -// it. +// A single 1x1 opaque black/white texture, reused by every consumer across a +// given `WebGL2RenderingContext` that needs to bind "nothing" (black) or a +// neutral, tintable surface (white) to a sampler2D uniform without a shader +// branch (for example a sprite with no emissive map, or a solid-color +// sprite rendered by tinting an untextured quad): allocating a fresh 1x1 +// texture per consumer would be wasteful, and none of them are ever freed +// since nothing owns one long enough to dispose it. const sharedBlackTextureByContext = new WeakMap< WebGL2RenderingContext, WebGLTexture >(); -/** - * Returns a shared, opaque-black, 1x1 texture for `gl`, creating it on first - * use. Intended for sampler2D uniforms that must always be bound to - * something (to avoid a data-dependent fragment shader branch) but should - * contribute nothing when no real texture is supplied. - * @param gl - The WebGL2 rendering context. - * @returns The shared black texture. - */ -export function getSharedBlackTexture( +const sharedWhiteTextureByContext = new WeakMap< + WebGL2RenderingContext, + WebGLTexture +>(); + +function getOrCreateSharedTexture( gl: WebGL2RenderingContext, + cache: WeakMap, + pixel: Uint8Array, ): WebGLTexture { - const existing = sharedBlackTextureByContext.get(gl); + const existing = cache.get(gl); if (existing) { return existing; @@ -38,10 +38,47 @@ export function getSharedBlackTexture( 0, gl.RGBA, gl.UNSIGNED_BYTE, - new Uint8Array([0, 0, 0, 255]), + pixel, ); - sharedBlackTextureByContext.set(gl, texture); + cache.set(gl, texture); return texture; } + +/** + * Returns a shared, opaque-black, 1x1 texture for `gl`, creating it on first + * use. Intended for sampler2D uniforms that must always be bound to + * something (to avoid a data-dependent fragment shader branch) but should + * contribute nothing when no real texture is supplied. + * @param gl - The WebGL2 rendering context. + * @returns The shared black texture. + */ +export function getSharedBlackTexture( + gl: WebGL2RenderingContext, +): WebGLTexture { + return getOrCreateSharedTexture( + gl, + sharedBlackTextureByContext, + new Uint8Array([0, 0, 0, 255]), + ); +} + +/** + * Returns a shared, opaque-white, 1x1 texture for `gl`, creating it on first + * use. Intended for a sprite's `u_texture` when it should render as a flat, + * tintable color rather than an image - the sprite shader multiplies the + * sampled texture color by `tintColor`, so a white texture leaves + * `tintColor` unmodified. + * @param gl - The WebGL2 rendering context. + * @returns The shared white texture. + */ +export function getSharedWhiteTexture( + gl: WebGL2RenderingContext, +): WebGLTexture { + return getOrCreateSharedTexture( + gl, + sharedWhiteTextureByContext, + new Uint8Array([255, 255, 255, 255]), + ); +} diff --git a/src/rendering/terrain/create-terrain-mesh.test.ts b/src/rendering/terrain/create-terrain-mesh.test.ts new file mode 100644 index 00000000..e6e5d4b3 --- /dev/null +++ b/src/rendering/terrain/create-terrain-mesh.test.ts @@ -0,0 +1,206 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { createTerrainMesh } from './create-terrain-mesh'; +import { buildTerrainCurve } from './terrain-curve'; +import { ImageCache } from '../../asset-loading/index.js'; +import { Vector2 } from '../../math/index.js'; +import { Color } from '../color.js'; +import { RenderContext } from '../render-context.js'; +import { ForgeShaderSource, ShaderCache } from '../shaders/index.js'; +import { terrainFragmentShader, terrainVertexShader } from './shaders/index'; + +// Mock WebGLTexture constructor for instanceof checks in Material.bind +globalThis.WebGLTexture = class WebGLTexture {}; + +describe('createTerrainMesh', () => { + let canvas: HTMLCanvasElement; + let mockGl: WebGL2RenderingContext; + let renderContext: RenderContext; + let fillImage: HTMLImageElement; + let borderImage: HTMLImageElement; + + const createOptions = ( + overrides: Partial[1]> = {}, + ) => ({ + curvePoints: buildTerrainCurve( + [new Vector2(0, 0), new Vector2(10, 5), new Vector2(20, 0)], + 4, + ), + depth: 50, + position: Vector2.zero, + angle: 0, + border: { + image: borderImage, + tileSize: new Vector2(20, 20), + tint: Color.white, + }, + fill: { + image: fillImage, + tileSize: new Vector2(30, 30), + tint: Color.white, + }, + borderWidth: 10, + ...overrides, + }); + + beforeEach(() => { + canvas = document.createElement('canvas'); + canvas.width = 800; + canvas.height = 600; + + fillImage = { width: 32, height: 32 } as HTMLImageElement; + borderImage = { width: 32, height: 32 } as HTMLImageElement; + + mockGl = { + VERTEX_SHADER: 'VERTEX_SHADER', + FRAGMENT_SHADER: 'FRAGMENT_SHADER', + COMPILE_STATUS: 'COMPILE_STATUS', + LINK_STATUS: 'LINK_STATUS', + ACTIVE_UNIFORMS: 'ACTIVE_UNIFORMS', + TEXTURE0: 0, + TEXTURE_2D: 'TEXTURE_2D', + ARRAY_BUFFER: 'ARRAY_BUFFER', + STATIC_DRAW: 'STATIC_DRAW', + CLAMP_TO_EDGE: 'CLAMP_TO_EDGE', + REPEAT: 'REPEAT', + TEXTURE_WRAP_S: 'TEXTURE_WRAP_S', + TEXTURE_WRAP_T: 'TEXTURE_WRAP_T', + TEXTURE_MIN_FILTER: 'TEXTURE_MIN_FILTER', + TEXTURE_MAG_FILTER: 'TEXTURE_MAG_FILTER', + LINEAR: 'LINEAR', + RGBA: 'RGBA', + UNSIGNED_BYTE: 'UNSIGNED_BYTE', + + createBuffer: vi.fn().mockReturnValue({}), + bindBuffer: vi.fn(), + bufferData: vi.fn(), + + createTexture: vi.fn().mockImplementation(() => new WebGLTexture()), + bindTexture: vi.fn(), + texParameteri: vi.fn(), + texImage2D: vi.fn(), + + createShader: vi.fn().mockReturnValue({}), + shaderSource: vi.fn(), + compileShader: vi.fn(), + getShaderParameter: vi.fn().mockReturnValue(true), + getShaderInfoLog: vi.fn().mockReturnValue(''), + + createProgram: vi.fn().mockReturnValue({}), + attachShader: vi.fn(), + linkProgram: vi.fn(), + getProgramParameter: vi + .fn() + .mockImplementation((_program: unknown, pname: unknown) => + pname === 'ACTIVE_UNIFORMS' ? 8 : true, + ), + getProgramInfoLog: vi.fn().mockReturnValue(''), + + getActiveUniform: vi.fn().mockImplementation( + (_program, index: number) => + [ + { name: 'u_fillTexture', type: 0, size: 1 }, + { name: 'u_borderTexture', type: 0, size: 1 }, + { name: 'u_fillTileSize', type: 0, size: 1 }, + { name: 'u_borderTileSize', type: 0, size: 1 }, + { name: 'u_fillTint', type: 0, size: 1 }, + { name: 'u_borderTint', type: 0, size: 1 }, + { name: 'u_borderWidth', type: 0, size: 1 }, + { name: 'u_borderBlend', type: 0, size: 1 }, + ][index] ?? null, + ), + getUniformLocation: vi + .fn() + .mockImplementation(() => ({}) as WebGLUniformLocation), + useProgram: vi.fn(), + uniform1i: vi.fn(), + uniform1f: vi.fn(), + uniform2fv: vi.fn(), + uniform4fv: vi.fn(), + activeTexture: vi.fn(), + } as unknown as WebGL2RenderingContext; + + vi.spyOn(canvas, 'getContext').mockReturnValue(mockGl); + + const shaderCache = new ShaderCache([]) + .addShader(new ForgeShaderSource(terrainVertexShader)) + .addShader(new ForgeShaderSource(terrainFragmentShader)); + + renderContext = new RenderContext(shaderCache, new ImageCache(), canvas); + }); + + it('does not throw when building a mesh', () => { + expect(() => + createTerrainMesh(renderContext, createOptions()), + ).not.toThrow(); + }); + + it('builds 6 vertices (2 triangles) per pair of consecutive curve points', () => { + const curvePoints = buildTerrainCurve( + [new Vector2(0, 0), new Vector2(10, 0)], + 5, + ); + + const mesh = createTerrainMesh( + renderContext, + createOptions({ curvePoints }), + ); + + expect(mesh.vertexCount).toBe((curvePoints.length - 1) * 6); + }); + + it('binds the configured border width uniform', () => { + const mesh = createTerrainMesh( + renderContext, + createOptions({ + borderWidth: 42, + }), + ); + + mesh.material.bind(mockGl); + + const calls = (mockGl.uniform1f as Mock).mock.calls; + const borderWidthCall = calls.find(([, value]) => value === 42); + + expect(borderWidthCall).toBeDefined(); + }); + + it('defaults borderBlend when not provided', () => { + const mesh = createTerrainMesh(renderContext, createOptions()); + + mesh.material.bind(mockGl); + + const calls = (mockGl.uniform1f as Mock).mock.calls; + const borderBlendCall = calls.find(([, value]) => value === 12); + + expect(borderBlendCall).toBeDefined(); + }); + + it('uses an explicit borderBlend when provided', () => { + const mesh = createTerrainMesh( + renderContext, + createOptions({ borderBlend: 30 }), + ); + + mesh.material.bind(mockGl); + + const calls = (mockGl.uniform1f as Mock).mock.calls; + const borderBlendCall = calls.find(([, value]) => value === 30); + + expect(borderBlendCall).toBeDefined(); + }); + + it('wraps both textures with REPEAT rather than CLAMP_TO_EDGE', () => { + createTerrainMesh(renderContext, createOptions()); + + const wrapCalls = (mockGl.texParameteri as Mock).mock.calls.filter( + ([, pname]) => pname === 'TEXTURE_WRAP_S', + ); + + expect(wrapCalls.length).toBeGreaterThan(0); + + for (const call of wrapCalls) { + expect(call[2]).toBe('REPEAT'); + } + }); +}); diff --git a/src/rendering/terrain/create-terrain-mesh.ts b/src/rendering/terrain/create-terrain-mesh.ts new file mode 100644 index 00000000..9ef2310b --- /dev/null +++ b/src/rendering/terrain/create-terrain-mesh.ts @@ -0,0 +1,236 @@ +import { Vector2 } from '../../math/index.js'; +import { Color } from '../color.js'; +import { Geometry } from '../geometry/index.js'; +import { Material } from '../materials/index.js'; +import { RenderContext } from '../render-context.js'; +import { createTextureFromImage } from '../shaders/index.js'; +import type { TerrainCurvePoint } from './terrain-curve.js'; + +/** + * Texture and tiling options for one of a terrain mesh's two texture + * layers (see {@link CreateTerrainMeshOptions}). + */ +export interface TerrainMeshLayerOptions { + /** + * The (already-loaded) image to tile across this layer - the same + * convention `createImageSprite` uses, rather than loading a URL itself. + */ + image: HTMLImageElement; + + /** + * The world-space size of one tile of the texture: x tiles along the + * curve's arc length, y tiles into the ground. Smaller values repeat the + * texture more often over the same span of terrain. + */ + tileSize: Vector2; + + /** Tint multiplied against the sampled texture. */ + tint: Color; +} + +/** + * Fields of {@link CreateTerrainMeshOptions} with a sensible default; + * callers may omit these. + */ +export interface CreateTerrainMeshDefaultedOptions { + /** + * How wide (in world units) the blend between the border and fill layers + * is, centered on `borderWidth`. + */ + borderBlend: number; +} + +export interface CreateTerrainMeshOptions extends Partial { + /** + * The dense curve points to build the mesh from - see `buildTerrainCurve`. + * Pass the same points used to build the corresponding `TerrainShape` (by + * mapping each `TerrainCurvePoint.position` into the `Vector2[]` + * `TerrainShape` expects), so what's drawn always matches what's touched. + */ + curvePoints: readonly TerrainCurvePoint[]; + + /** + * How far (in world units) the mesh extends below its lowest curve + * point. Must match the `depth` passed to the corresponding + * `TerrainShape` for the mesh to align with the collision volume. + */ + depth: number; + + /** + * The world-space position of the mesh. Must match the corresponding + * `RigidBody`'s `position`. + */ + position: Vector2; + + /** + * The world-space rotation of the mesh, in radians. Must match the + * corresponding `RigidBody`'s `angle`. + */ + angle: number; + + /** The texture tiled across the terrain's surface, from the surface down to `borderWidth`. */ + border: TerrainMeshLayerOptions; + + /** The texture tiled across the terrain's interior, below `borderWidth`. */ + fill: TerrainMeshLayerOptions; + + /** + * How deep (in world units) the border layer extends below the surface + * before blending into the fill layer. + */ + borderWidth: number; +} + +const defaultCreateTerrainMeshOptions: CreateTerrainMeshDefaultedOptions = { + borderBlend: 12, +}; + +/** + * The static terrain mesh built by {@link createTerrainMesh}, ready to draw + * with `createTerrainRenderEcsSystem`. + */ +export interface TerrainMesh { + readonly geometry: Geometry; + readonly material: Material; + readonly vertexCount: number; +} + +interface TerrainMeshData { + positions: Float32Array; + distances: Float32Array; + depths: Float32Array; + vertexCount: number; +} + +function buildTerrainMeshData( + curvePoints: readonly TerrainCurvePoint[], + bottomY: number, + angle: number, + position: Vector2, +): TerrainMeshData { + const positions: number[] = []; + const distances: number[] = []; + const depths: number[] = []; + + const pushVertex = ( + localPoint: Vector2, + distance: number, + depth: number, + ): void => { + const world = localPoint.rotate(angle).add(position); + + // Y is negated here to match the sprite pipeline's world-to-render + // convention (see sprite-instance-data-segment.ts's + // bindSpriteInstanceData, which negates position.world.y the same way + // before it reaches the GPU). + positions.push(world.x, -world.y); + distances.push(distance); + depths.push(depth); + }; + + for (let i = 0; i < curvePoints.length - 1; i++) { + const left = curvePoints[i]; + const right = curvePoints[i + 1]; + + const bottomLeft = new Vector2(left.position.x, bottomY); + const bottomRight = new Vector2(right.position.x, bottomY); + const depthLeft = bottomY - left.position.y; + const depthRight = bottomY - right.position.y; + + pushVertex(left.position, left.distance, 0); + pushVertex(right.position, right.distance, 0); + pushVertex(bottomLeft, left.distance, depthLeft); + + pushVertex(right.position, right.distance, 0); + pushVertex(bottomRight, right.distance, depthRight); + pushVertex(bottomLeft, left.distance, depthLeft); + } + + return { + positions: new Float32Array(positions), + distances: new Float32Array(distances), + depths: new Float32Array(depths), + vertexCount: positions.length / 2, + }; +} + +function createTerrainGeometry( + gl: WebGL2RenderingContext, + meshData: TerrainMeshData, +): Geometry { + const geometry = new Geometry(); + + const positionBuffer = gl.createBuffer(); + + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.bufferData(gl.ARRAY_BUFFER, meshData.positions, gl.STATIC_DRAW); + geometry.addAttribute(gl, 'a_position', { buffer: positionBuffer, size: 2 }); + + const distanceBuffer = gl.createBuffer(); + + gl.bindBuffer(gl.ARRAY_BUFFER, distanceBuffer); + gl.bufferData(gl.ARRAY_BUFFER, meshData.distances, gl.STATIC_DRAW); + geometry.addAttribute(gl, 'a_distance', { + buffer: distanceBuffer, + size: 1, + }); + + const depthBuffer = gl.createBuffer(); + + gl.bindBuffer(gl.ARRAY_BUFFER, depthBuffer); + gl.bufferData(gl.ARRAY_BUFFER, meshData.depths, gl.STATIC_DRAW); + geometry.addAttribute(gl, 'a_depth', { buffer: depthBuffer, size: 1 }); + + return geometry; +} + +/** + * Builds a single triangulated mesh visualizing a `TerrainShape`'s + * heightmap, textured with a tileable "border" layer near the surface + * blending into a tileable "fill" layer below it (see + * `CreateTerrainMeshOptions`). Since the mesh has an arbitrary vertex count + * - not the fixed six-vertices-per-quad the sprite pipeline batches - draw + * it with `createTerrainRenderEcsSystem` rather than through + * `createRenderEcsSystem`. + * @param renderContext - The render context used to build the mesh's geometry and material. + * @param options - Sizing and texturing options for the mesh. + * @returns The built `TerrainMesh`. + */ +export function createTerrainMesh( + renderContext: RenderContext, + options: CreateTerrainMeshOptions, +): TerrainMesh { + const { curvePoints, depth, position, angle, border, fill, borderWidth } = + options; + const { borderBlend } = { ...defaultCreateTerrainMeshOptions, ...options }; + const { gl, shaderCache } = renderContext; + + const bottomY = + Math.max(...curvePoints.map((curvePoint) => curvePoint.position.y)) + depth; + + const meshData = buildTerrainMeshData(curvePoints, bottomY, angle, position); + const geometry = createTerrainGeometry(gl, meshData); + + const material = new Material( + shaderCache.getShader('terrain.vert'), + shaderCache.getShader('terrain.frag'), + gl, + ); + + material.setUniform( + 'u_fillTexture', + createTextureFromImage(gl, fill.image, false, true), + ); + material.setUniform( + 'u_borderTexture', + createTextureFromImage(gl, border.image, false, true), + ); + material.setVectorUniform('u_fillTileSize', fill.tileSize); + material.setVectorUniform('u_borderTileSize', border.tileSize); + material.setColorUniform('u_fillTint', fill.tint); + material.setColorUniform('u_borderTint', border.tint); + material.setUniform('u_borderWidth', borderWidth); + material.setUniform('u_borderBlend', borderBlend); + + return { geometry, material, vertexCount: meshData.vertexCount }; +} diff --git a/src/rendering/terrain/create-terrain-render-ecs-system.test.ts b/src/rendering/terrain/create-terrain-render-ecs-system.test.ts new file mode 100644 index 00000000..7b5ab2f3 --- /dev/null +++ b/src/rendering/terrain/create-terrain-render-ecs-system.test.ts @@ -0,0 +1,142 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { createTerrainRenderEcsSystem } from './create-terrain-render-ecs-system'; +import type { TerrainMesh } from './create-terrain-mesh'; +import { EcsWorld } from '../../ecs/index.js'; +import { PositionEcsComponent, positionId } from '../../common/index.js'; +import { Vector2 } from '../../math/index.js'; +import { CameraEcsComponent, cameraId } from '../components/index.js'; +import { ImageCache } from '../../asset-loading/index.js'; +import { RenderContext } from '../render-context.js'; +import { ShaderCache } from '../shaders/index.js'; +import { Geometry } from '../geometry/index.js'; +import { Material } from '../materials/index.js'; +import { Color } from '../color.js'; + +describe('createTerrainRenderEcsSystem', () => { + let world: EcsWorld; + let canvas: HTMLCanvasElement; + let mockGl: WebGL2RenderingContext; + let renderContext: RenderContext; + let terrainMesh: TerrainMesh; + let material: Material; + let geometry: Geometry; + + const createCameraComponent = ( + overrides: Partial = {}, + ): CameraEcsComponent => ({ + zoom: 1, + zoomSensitivity: 0.1, + panSensitivity: 1, + minZoom: 0.0001, + maxZoom: 10000, + isStatic: true, + cullingMask: 0xffffffff, + layer: 0, + clearColor: Color.transparent, + ...overrides, + }); + + beforeEach(() => { + canvas = document.createElement('canvas'); + canvas.width = 800; + canvas.height = 600; + + mockGl = { + FRAMEBUFFER: 'FRAMEBUFFER', + COLOR_BUFFER_BIT: 'COLOR_BUFFER_BIT', + TRIANGLES: 'TRIANGLES', + bindFramebuffer: vi.fn(), + viewport: vi.fn(), + clearColor: vi.fn(), + clear: vi.fn(), + drawArrays: vi.fn(), + createBuffer: vi.fn().mockReturnValue({}), + } as unknown as WebGL2RenderingContext; + + vi.spyOn(canvas, 'getContext').mockReturnValue(mockGl); + + renderContext = new RenderContext( + new ShaderCache([]), + new ImageCache(), + canvas, + ); + + material = { + bind: vi.fn(), + setUniform: vi.fn(), + program: {} as WebGLProgram, + } as unknown as Material; + + geometry = { + bind: vi.fn(), + } as unknown as Geometry; + + terrainMesh = { geometry, material, vertexCount: 42 }; + + world = new EcsWorld(); + world.addSystem(createTerrainRenderEcsSystem(renderContext, terrainMesh)); + }); + + const addCamera = (overrides: Partial = {}): void => { + const entity = world.createEntity(); + const positionComponent: PositionEcsComponent = { + local: Vector2.zero, + world: new Vector2(10, 20), + }; + + world.addComponent(entity, cameraId, createCameraComponent(overrides)); + world.addComponent(entity, positionId, positionComponent); + }; + + it('does not draw anything when there is no camera entity', () => { + world.update(); + + expect(mockGl.drawArrays).not.toHaveBeenCalled(); + }); + + it('clears and draws the terrain mesh once per camera', () => { + addCamera(); + + world.update(); + + expect(mockGl.clear).toHaveBeenCalledWith('COLOR_BUFFER_BIT'); + expect(material.bind).toHaveBeenCalledWith(mockGl); + expect(geometry.bind).toHaveBeenCalledWith(mockGl, material.program); + expect(mockGl.drawArrays).toHaveBeenCalledWith('TRIANGLES', 0, 42); + }); + + it('sets the projection matrix uniform before binding the material', () => { + addCamera(); + + world.update(); + + expect(material.setUniform).toHaveBeenCalledWith( + 'u_projection', + expect.anything(), + ); + + const setUniformOrder = (material.setUniform as Mock).mock + .invocationCallOrder[0]; + const bindOrder = (material.bind as Mock).mock.invocationCallOrder[0]; + + expect(setUniformOrder).toBeLessThan(bindOrder); + }); + + it('binds the canvas framebuffer when the camera has no render target', () => { + addCamera(); + + world.update(); + + expect(mockGl.bindFramebuffer).toHaveBeenCalledWith('FRAMEBUFFER', null); + }); + + it('draws once per camera when multiple cameras are present', () => { + addCamera(); + addCamera(); + + world.update(); + + expect(mockGl.drawArrays).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/rendering/terrain/create-terrain-render-ecs-system.ts b/src/rendering/terrain/create-terrain-render-ecs-system.ts new file mode 100644 index 00000000..79fd8348 --- /dev/null +++ b/src/rendering/terrain/create-terrain-render-ecs-system.ts @@ -0,0 +1,54 @@ +import { EcsSystem } from '../../ecs/index.js'; +import { PositionEcsComponent, positionId } from '../../common/index.js'; +import { CameraEcsComponent, cameraId } from '../components/index.js'; +import { RenderContext } from '../render-context.js'; +import { createProjectionMatrix } from '../shaders/index.js'; +import type { TerrainMesh } from './create-terrain-mesh.js'; + +/** + * Creates an ECS system that draws `terrainMesh` directly - a single, + * non-instanced `gl.drawArrays` call against its own geometry and material + * - rather than going through the sprite pipeline `createRenderEcsSystem` + * batches (which only knows how to draw quads). Register it *before* + * `createRenderEcsSystem`, with `renderContext.clearStrategy` set to + * `CLEAR_STRATEGY.none`, so this system's own clear is the only one each + * frame - `createRenderEcsSystem`'s would otherwise wipe the terrain right + * before drawing sprites on top of it. + * + * Assumes a single camera rendering straight to the canvas; a multi-camera + * setup would need to track which destinations have already been cleared + * this frame, the way `createRenderEcsSystem` does internally. + * @param renderContext - The render context to draw into. + * @param terrainMesh - The terrain mesh built by `createTerrainMesh`. + */ +export const createTerrainRenderEcsSystem = ( + renderContext: RenderContext, + terrainMesh: TerrainMesh, +): EcsSystem<[CameraEcsComponent, PositionEcsComponent]> => ({ + query: [cameraId, positionId], + run: (result) => { + const [cameraComponent, positionComponent] = result.components; + const { gl } = renderContext; + const { geometry, material, vertexCount } = terrainMesh; + + renderContext.bindRenderTarget(cameraComponent.renderTarget ?? null); + + const { clearColor } = cameraComponent; + + gl.clearColor(clearColor.r, clearColor.g, clearColor.b, clearColor.a); + gl.clear(gl.COLOR_BUFFER_BIT); + + const projectionMatrix = createProjectionMatrix( + renderContext.width, + renderContext.height, + positionComponent.world, + cameraComponent.zoom, + ); + + material.setUniform('u_projection', projectionMatrix); + material.bind(gl); + geometry.bind(gl, material.program); + + gl.drawArrays(gl.TRIANGLES, 0, vertexCount); + }, +}); diff --git a/src/rendering/terrain/index.ts b/src/rendering/terrain/index.ts new file mode 100644 index 00000000..b3d313a0 --- /dev/null +++ b/src/rendering/terrain/index.ts @@ -0,0 +1,4 @@ +export * from './create-terrain-mesh.js'; +export * from './create-terrain-render-ecs-system.js'; +export * from './terrain-curve.js'; +export * from './shaders/index.js'; diff --git a/src/rendering/terrain/shaders/index.ts b/src/rendering/terrain/shaders/index.ts new file mode 100644 index 00000000..1eee6bbe --- /dev/null +++ b/src/rendering/terrain/shaders/index.ts @@ -0,0 +1,5 @@ +import terrainVertexShaderSource from './terrain.vert.glsl?raw'; +import terrainFragmentShaderSource from './terrain.frag.glsl?raw'; + +export const terrainVertexShader = terrainVertexShaderSource; +export const terrainFragmentShader = terrainFragmentShaderSource; diff --git a/src/rendering/terrain/shaders/terrain.frag.glsl b/src/rendering/terrain/shaders/terrain.frag.glsl new file mode 100644 index 00000000..5ffa98ff --- /dev/null +++ b/src/rendering/terrain/shaders/terrain.frag.glsl @@ -0,0 +1,39 @@ +#version 300 es + +#pragma forge name(terrain.frag) + +precision mediump float; + +uniform sampler2D u_fillTexture; +uniform sampler2D u_borderTexture; +uniform vec2 u_fillTileSize; +uniform vec2 u_borderTileSize; +uniform vec4 u_fillTint; +uniform vec4 u_borderTint; +uniform float u_borderWidth; +uniform float u_borderBlend; + +in float v_distance; +in float v_depth; + +out vec4 fragColor; + +void main() { + vec2 fillUv = vec2(v_distance / u_fillTileSize.x, v_depth / u_fillTileSize.y); + vec2 borderUv = + vec2(v_distance / u_borderTileSize.x, v_depth / u_borderTileSize.y); + + vec4 fillColor = texture(u_fillTexture, fillUv) * u_fillTint; + vec4 borderColor = texture(u_borderTexture, borderUv) * u_borderTint; + + // 0 near the surface (border texture), 1 once past the border band (fill + // texture), smoothed across `u_borderBlend` so the two textures cross-fade + // instead of cutting sharply from one to the other. + float t = smoothstep( + u_borderWidth - u_borderBlend, + u_borderWidth + u_borderBlend, + v_depth + ); + + fragColor = mix(borderColor, fillColor, t); +} diff --git a/src/rendering/terrain/shaders/terrain.vert.glsl b/src/rendering/terrain/shaders/terrain.vert.glsl new file mode 100644 index 00000000..69e037db --- /dev/null +++ b/src/rendering/terrain/shaders/terrain.vert.glsl @@ -0,0 +1,20 @@ +#version 300 es + +#pragma forge name(terrain.vert) + +in vec2 a_position; +in float a_distance; +in float a_depth; + +uniform mat3 u_projection; + +out float v_distance; +out float v_depth; + +void main() { + vec3 projected = u_projection * vec3(a_position, 1.0); + + gl_Position = vec4(projected.xy, 0.0, 1.0); + v_distance = a_distance; + v_depth = a_depth; +} diff --git a/src/rendering/terrain/terrain-curve.test.ts b/src/rendering/terrain/terrain-curve.test.ts new file mode 100644 index 00000000..c30a20d1 --- /dev/null +++ b/src/rendering/terrain/terrain-curve.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { buildTerrainCurve, heightAtLocalX } from './terrain-curve.js'; +import { Vector2 } from '../../math/index.js'; + +describe('buildTerrainCurve', () => { + it('should throw an error if fewer than 2 control points are provided', () => { + expect(() => buildTerrainCurve([new Vector2(0, 0)], 4)).toThrow(); + }); + + it('should start at the first control point', () => { + const curve = buildTerrainCurve( + [new Vector2(0, 5), new Vector2(10, 5), new Vector2(20, 5)], + 4, + ); + + expect(curve[0].position.x).toBeCloseTo(0); + expect(curve[0].position.y).toBeCloseTo(5); + expect(curve[0].distance).toBeCloseTo(0); + }); + + it('should pass exactly through every control point', () => { + const controlPoints = [ + new Vector2(0, 0), + new Vector2(10, 40), + new Vector2(20, -20), + new Vector2(30, 10), + ]; + const samplesPerSegment = 6; + + const curve = buildTerrainCurve(controlPoints, samplesPerSegment); + + for (let i = 0; i < controlPoints.length; i++) { + const sampleIndex = i * samplesPerSegment; + + expect(curve[sampleIndex].position.x).toBeCloseTo(controlPoints[i].x); + expect(curve[sampleIndex].position.y).toBeCloseTo(controlPoints[i].y); + } + }); + + it('should produce a straight line for evenly-spaced collinear control points', () => { + const curve = buildTerrainCurve( + [ + new Vector2(0, 0), + new Vector2(10, 10), + new Vector2(20, 20), + new Vector2(30, 30), + ], + 5, + ); + + for (const point of curve) { + expect(point.position.y).toBeCloseTo(point.position.x); + } + }); + + it('should produce monotonically increasing x and distance', () => { + const curve = buildTerrainCurve( + [ + new Vector2(0, 0), + new Vector2(10, 50), + new Vector2(20, -30), + new Vector2(30, 0), + ], + 8, + ); + + for (let i = 1; i < curve.length; i++) { + expect(curve[i].position.x).toBeGreaterThan(curve[i - 1].position.x); + expect(curve[i].distance).toBeGreaterThan(curve[i - 1].distance); + } + }); +}); + +describe('heightAtLocalX', () => { + it('should throw an error if given an empty curve', () => { + expect(() => heightAtLocalX([], 0)).toThrow(); + }); + + it('should return the exact height at a sampled point', () => { + const curve = buildTerrainCurve( + [new Vector2(0, 0), new Vector2(10, 0), new Vector2(20, 0)], + 4, + ); + + expect(heightAtLocalX(curve, 10)).toBeCloseTo(0); + }); + + it('should linearly interpolate between two bracketing points', () => { + const curve = [ + { position: new Vector2(0, 0), distance: 0 }, + { position: new Vector2(10, 20), distance: 10 }, + ]; + + expect(heightAtLocalX(curve, 5)).toBeCloseTo(10); + }); + + it('should clamp to the first point before the start of the curve', () => { + const curve = [ + { position: new Vector2(0, 7), distance: 0 }, + { position: new Vector2(10, 20), distance: 10 }, + ]; + + expect(heightAtLocalX(curve, -100)).toBeCloseTo(7); + }); + + it('should clamp to the last point past the end of the curve', () => { + const curve = [ + { position: new Vector2(0, 7), distance: 0 }, + { position: new Vector2(10, 20), distance: 10 }, + ]; + + expect(heightAtLocalX(curve, 100)).toBeCloseTo(20); + }); +}); diff --git a/src/rendering/terrain/terrain-curve.ts b/src/rendering/terrain/terrain-curve.ts new file mode 100644 index 00000000..185bdc47 --- /dev/null +++ b/src/rendering/terrain/terrain-curve.ts @@ -0,0 +1,142 @@ +import { Vector2 } from '../../math/index.js'; + +/** + * A point on a curve built by {@link buildTerrainCurve}. + */ +export interface TerrainCurvePoint { + /** + * The point's local-space position. + */ + position: Vector2; + + /** + * The cumulative arc length from the start of the curve to this point. + */ + distance: number; +} + +function sampleCubicBezier( + b0: Vector2, + b1: Vector2, + b2: Vector2, + b3: Vector2, + t: number, +): Vector2 { + const oneMinusT = 1 - t; + const w0 = oneMinusT * oneMinusT * oneMinusT; + const w1 = 3 * oneMinusT * oneMinusT * t; + const w2 = 3 * oneMinusT * t * t; + const w3 = t * t * t; + + return b0 + .multiply(w0) + .add(b1.multiply(w1)) + .add(b2.multiply(w2)) + .add(b3.multiply(w3)); +} + +/** + * Builds a smooth curve through `controlPoints`, using a Catmull-Rom spline + * (converted to an equivalent sequence of cubic Beziers, one per segment, + * via the standard `Bn = Pn +/- (Pn+1 - Pn-1) / 6` tangent construction) so + * the curve passes exactly through every control point with a continuous + * tangent, rather than a straight-line polyline between them. Densely + * sampling that curve turns a handful of sparse control points into a long, + * natural-looking silhouette suitable for both a `TerrainShape`'s collision + * points and a matching render mesh (see `createTerrainMesh`) - the same + * points can drive both, so what's drawn always matches what's touched. + * @param controlPoints - The sparse anchor points the curve passes through, + * ordered by strictly increasing x. Must contain at least 2 points. + * @param samplesPerSegment - How many points to sample along each segment + * between two consecutive control points. + * @returns A dense polyline approximating the smooth curve, with each + * point's cumulative arc length from the start. + * @throws An error if fewer than 2 control points are provided. + */ +export function buildTerrainCurve( + controlPoints: readonly Vector2[], + samplesPerSegment: number, +): TerrainCurvePoint[] { + if (controlPoints.length < 2) { + throw new Error( + `buildTerrainCurve requires at least 2 control points, received ${controlPoints.length}.`, + ); + } + + const curvePoints: TerrainCurvePoint[] = [ + { position: controlPoints[0].clone(), distance: 0 }, + ]; + + for (let i = 0; i < controlPoints.length - 1; i++) { + const p0 = controlPoints[i > 0 ? i - 1 : i]; + const p1 = controlPoints[i]; + const p2 = controlPoints[i + 1]; + const p3 = controlPoints[i < controlPoints.length - 2 ? i + 2 : i + 1]; + + const b0 = p1; + const b1 = p1.add(p2.subtract(p0).multiply(1 / 6)); + const b2 = p2.subtract(p3.subtract(p1).multiply(1 / 6)); + const b3 = p2; + + for (let sample = 1; sample <= samplesPerSegment; sample++) { + const t = sample / samplesPerSegment; + const position = sampleCubicBezier(b0, b1, b2, b3, t); + const previous = curvePoints[curvePoints.length - 1]; + const distance = + previous.distance + position.distanceTo(previous.position); + + curvePoints.push({ position, distance }); + } + } + + return curvePoints; +} + +/** + * Finds a curve's surface height (local-space y) at a given local-space x, + * linearly interpolating between the two bracketing sampled points. + * `curvePoints` must be ordered by strictly increasing x (as returned by + * {@link buildTerrainCurve}). + * @param curvePoints - The dense curve to search. Must contain at least 1 point. + * @param localX - The local-space x to find the height at. + * @returns The interpolated local-space y at `localX`. + * @throws An error if `curvePoints` is empty. + */ +export function heightAtLocalX( + curvePoints: readonly TerrainCurvePoint[], + localX: number, +): number { + if (curvePoints.length === 0) { + throw new Error('heightAtLocalX requires at least 1 curve point.'); + } + + const first = curvePoints[0]; + const last = curvePoints[curvePoints.length - 1]; + + if (localX <= first.position.x) { + return first.position.y; + } + + if (localX >= last.position.x) { + return last.position.y; + } + + let low = 0; + let high = curvePoints.length - 1; + + while (high - low > 1) { + const mid = (low + high) >> 1; + + if (curvePoints[mid].position.x <= localX) { + low = mid; + } else { + high = mid; + } + } + + const a = curvePoints[low]; + const b = curvePoints[high]; + const t = (localX - a.position.x) / (b.position.x - a.position.x); + + return a.position.y + (b.position.y - a.position.y) * t; +} diff --git a/src/rendering/utilities/create-shader-cache.ts b/src/rendering/utilities/create-shader-cache.ts index 5102e4ad..041bc4d4 100644 --- a/src/rendering/utilities/create-shader-cache.ts +++ b/src/rendering/utilities/create-shader-cache.ts @@ -27,6 +27,10 @@ import { spriteVertexShader, toneMappingFragmentShader, } from '../shaders/index.js'; +import { + terrainFragmentShader, + terrainVertexShader, +} from '../terrain/shaders/index.js'; /** * Creates and initializes a ShaderCache instance with predefined shader includes and shaders. @@ -71,7 +75,9 @@ export function createShaderCache(): ShaderCache { .addShader(new ForgeShaderSource(crossFadeFragmentShader)) .addShader(new ForgeShaderSource(bloomThresholdFragmentShader)) .addShader(new ForgeShaderSource(bloomCompositeFragmentShader)) - .addShader(new ForgeShaderSource(toneMappingFragmentShader)); + .addShader(new ForgeShaderSource(toneMappingFragmentShader)) + .addShader(new ForgeShaderSource(terrainVertexShader)) + .addShader(new ForgeShaderSource(terrainFragmentShader)); return shaderCache; }