From 97adfe5c15cce2a0d14ef69ddd527d6317b067d6 Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 8 Oct 2025 10:06:59 +0900 Subject: [PATCH] feat: add tile map rendering helper --- PROJECT_DESCRIPTION.md | 4 +- README.md | 4 +- ROADMAP.md | 2 +- docs/index.d.ts | 85 ++++++++++++ examples/tileMap.ts | 30 ++++ src/gameplay/tileMap.ts | 301 ++++++++++++++++++++++++++++++++++++++++ src/index.ts | 20 +++ tests/index.test.ts | 2 + tests/tileMap.test.ts | 61 ++++++++ 9 files changed, 504 insertions(+), 5 deletions(-) create mode 100644 examples/tileMap.ts create mode 100644 src/gameplay/tileMap.ts create mode 100644 tests/tileMap.test.ts diff --git a/PROJECT_DESCRIPTION.md b/PROJECT_DESCRIPTION.md index b545320..eaa654e 100644 --- a/PROJECT_DESCRIPTION.md +++ b/PROJECT_DESCRIPTION.md @@ -39,7 +39,7 @@ npm run build | Procedural textures & terrain | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem`, `generateBspDungeon`, `generateRecursiveMaze`, `generatePrimMaze`, `generateKruskalMaze`, `generateWilsonMaze`, `generateAldousBroderMaze`, `generateRecursiveDivisionMaze` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts` | | Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` | | Web performance & UI throttling | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts` | -| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem`, `createPlatformerController`, `createTopDownController` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts`, `gameplay/topDownMovement.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts` | +| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem`, `createPlatformerController`, `createTopDownController`, `createTileMapController` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts`, `gameplay/topDownMovement.ts`, `gameplay/tileMap.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts` | | Text & search | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance` | `search/*.ts` | `examples/search.ts` | | Data transforms & diffing | `diff`, `deepClone`, `groupBy`, `diffJson`, `applyJsonDiff` | `data/*.ts` | `examples/jsonDiff.ts` | | Graph traversal | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` | @@ -94,7 +94,7 @@ Consistency between runtime code, documentation, and TypeScript declarations kee - **Procedural:** 2D/3D Perlin, Worley noise, Wave Function Collapse tile synthesis. - **Spatial:** Quadtree, AABB helpers, SAT convex polygon collision. - **Performance utilities:** Debounce, throttle, LRU cache, memoize, request deduplication, virtual scrolling, weighted alias sampling, object pooling, Fisher–Yates shuffle. -- **Gameplay systems:** Delta-time manager, fixed timestep loop, 2D camera with smoothing and shake, particle system with configurable emitters, sprite animation controller with frame events, tween system with easing and repeats, platformer physics helper with coyote time and jump buffering, top-down movement controller with acceleration and drag. +- **Gameplay systems:** Delta-time manager, fixed timestep loop, 2D camera with smoothing and shake, particle system with configurable emitters, sprite animation controller with frame events, tween system with easing and repeats, platformer physics helper with coyote time and jump buffering, top-down movement controller with acceleration and drag, tile map renderer with chunking and collision tags. - **Search:** Fuzzy search + scoring, Trie-based autocomplete, binary search, Levenshtein distance. - **Data tools:** Diff operations (LCS), deep clone, groupBy, JSON diff/patch helpers. - **Graph:** BFS distance map, DFS traversal, topological sort. diff --git a/README.md b/README.md index b279fbd..10b1d89 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ CDN usage: | Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` | | AI behaviours & crowds | `seek`, `flee`, `arrive`, `pursue`, `wander`, `updateBoids`, `BehaviorTree`, `rvoStep` | `ai/steering.ts`, `ai/boids.ts`, `ai/behaviorTree.ts`, `ai/rvo.ts` | `examples/steering.ts`, `examples/boids.ts`, `examples/rvo.ts` | | Web performance & UI | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts` | -| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem`, `createPlatformerController`, `createTopDownController` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts`, `gameplay/topDownMovement.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts` | +| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem`, `createSpriteAnimation`, `createTweenSystem`, `createPlatformerController`, `createTopDownController`, `createTileMapController` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts`, `gameplay/spriteAnimation.ts`, `gameplay/tween.ts`, `gameplay/platformerPhysics.ts`, `gameplay/topDownMovement.ts`, `gameplay/tileMap.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts` | | Search & text | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance` | `search/*.ts` | `examples/search.ts` | | Data & diff pipelines | `diff`, `deepClone`, `groupBy`, `diffJson`, `applyJsonDiff` | `data/*.ts` | `examples/jsonDiff.ts` | | Graph algorithms | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` | @@ -53,7 +53,7 @@ npm run size # Enforce bundle size budget - Milestone 0.2 next targets crowd-flow integrations (RVO + flow fields) and behaviour-tree decorators for richer AI control. - Milestone 0.4 plans a procedural + gameplay systems toolkit (Wave Function Collapse, dungeon suite, L-systems, game loop, camera, particles, inventory, combat, save/load, and more). -Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically. +Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/spriteAnimation.ts`, `examples/tween.ts`, `examples/platformerPhysics.ts`, `examples/topDownMovement.ts`, `examples/tileMap.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically. ## Contributing 1. Fork the repository. diff --git a/ROADMAP.md b/ROADMAP.md index 0d2e651..375a2a3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -57,7 +57,7 @@ - [x] Tween/lerp utility for smooth interpolation - [x] Platformer physics helper (gravity, coyote time, jump buffering) - [x] Top-down movement helper (8-direction) - - [ ] Tile map renderer helpers (chunking, layering, collision tags) + - [x] Tile map renderer helpers (chunking, layering, collision tags) - [ ] Shadowcasting field-of-view utilities and minimap helpers - **Systems for gameplay loops** - [ ] Inventory system primitives (stacking, filtering, persistence hooks) diff --git a/docs/index.d.ts b/docs/index.d.ts index b2f863c..cd11bde 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -1363,6 +1363,91 @@ export function createTopDownController( initialState?: TopDownState ): TopDownController; +/** + * Tile map layer definition. + * Use for: storing tile ids per layer with optional collision mask. + * Import: gameplay/tileMap.ts + */ +export interface TileMapLayer { + name: string; + data: ReadonlyArray; + collision?: ReadonlyArray; +} + +/** + * Tile map configuration. + * Use for: describing map dimensions, tile size, layers, and chunking. + * Import: gameplay/tileMap.ts + */ +export interface TileMapOptions { + width: number; + height: number; + tileWidth: number; + tileHeight: number; + layers: ReadonlyArray; + chunkWidth?: number; + chunkHeight?: number; +} + +/** + * Tile map viewport in world coordinates. + * Use for: determining visible tiles/chunks. + * Import: gameplay/tileMap.ts + */ +export interface TileMapViewport { + x: number; + y: number; + width: number; + height: number; +} + +/** + * Tile chunk coordinate. + * Use for: referencing chunks during rendering or streaming. + * Import: gameplay/tileMap.ts + */ +export interface ChunkCoordinate { + cx: number; + cy: number; +} + +/** + * Visible tile description. + * Use for: rendering visible tiles with layer and world coordinates. + * Import: gameplay/tileMap.ts + */ +export interface VisibleTile { + layer: string; + tileIndex: number; + tileId: number; + mapX: number; + mapY: number; + worldX: number; + worldY: number; +} + +/** + * Tile map controller API. + * Use for: querying tiles, collisions, visible tiles/chunks. + * Import: gameplay/tileMap.ts + */ +export interface TileMapController { + getTile(layerName: string, x: number, y: number): number; + setTile(layerName: string, x: number, y: number, tileId: number): void; + isCollidable(x: number, y: number): boolean; + getVisibleTiles(viewport: TileMapViewport): VisibleTile[]; + getVisibleChunks(viewport: TileMapViewport): ChunkCoordinate[]; + getChunkSize(): Vector2D; +} + +/** + * Creates a tile map controller for chunked rendering and collision checks. + * Use for: tile map streaming, layering, and collision tagging. + * Performance: O(visible tiles) per query. + * Import: gameplay/tileMap.ts + */ +export function createTileMapController(options: TileMapOptions): TileMapController; + /** * Least recently used cache. * Use for: memoizing responses, data loaders, pagination caches. diff --git a/examples/tileMap.ts b/examples/tileMap.ts new file mode 100644 index 0000000..9b9c616 --- /dev/null +++ b/examples/tileMap.ts @@ -0,0 +1,30 @@ +import { createTileMapController } from '../src/index.js'; + +const width = 8; +const height = 6; +const tiles = Array(width * height).fill(0); +tiles[0] = 1; +tiles[7] = 2; +tiles[width * 2 + 3] = 3; + +const tileMap = createTileMapController({ + width, + height, + tileWidth: 32, + tileHeight: 32, + chunkWidth: 4, + chunkHeight: 3, + layers: [ + { + name: 'ground', + data: tiles, + }, + ], +}); + +const viewport = { x: 0, y: 0, width: 128, height: 96 }; +const visible = tileMap.getVisibleTiles(viewport); +console.log('visible tiles:', visible); + +const chunks = tileMap.getVisibleChunks(viewport); +console.log('visible chunks:', chunks); diff --git a/src/gameplay/tileMap.ts b/src/gameplay/tileMap.ts new file mode 100644 index 0000000..bc86516 --- /dev/null +++ b/src/gameplay/tileMap.ts @@ -0,0 +1,301 @@ +import type { Vector2D } from '../types.js'; + +export interface TileMapLayer { + name: string; + data: ReadonlyArray; + collision?: ReadonlyArray; +} + +export interface TileMapOptions { + width: number; + height: number; + tileWidth: number; + tileHeight: number; + layers: ReadonlyArray; + chunkWidth?: number; + chunkHeight?: number; +} + +export interface TileMapViewport { + x: number; + y: number; + width: number; + height: number; +} + +export interface ChunkCoordinate { + cx: number; + cy: number; +} + +export interface VisibleTile { + layer: string; + tileIndex: number; + tileId: number; + mapX: number; + mapY: number; + worldX: number; + worldY: number; +} + +export interface TileMapController { + getTile(layerName: string, x: number, y: number): number; + setTile(layerName: string, x: number, y: number, tileId: number): void; + isCollidable(x: number, y: number): boolean; + getVisibleTiles(viewport: TileMapViewport): VisibleTile[]; + getVisibleChunks(viewport: TileMapViewport): ChunkCoordinate[]; + getChunkSize(): Vector2D; +} + +interface NormalizedLayer { + name: string; + data: Uint32Array; + collision?: boolean[]; +} + +interface InternalOptions { + width: number; + height: number; + tileWidth: number; + tileHeight: number; + chunkWidth: number; + chunkHeight: number; + layers: NormalizedLayer[]; +} + +function assertPositiveInt(value: number, label: string): void { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer.`); + } +} + +function assertFinite(value: number, label: string): void { + if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number.`); + } +} + +function normalizeLayer(layer: TileMapLayer, width: number, height: number): NormalizedLayer { + if (!layer || typeof layer.name !== 'string' || layer.name.length === 0) { + throw new Error('layer.name must be a non-empty string.'); + } + if (!Array.isArray(layer.data)) { + throw new Error(`layer "${layer.name}" data must be an array.`); + } + if (layer.data.length !== width * height) { + throw new Error(`layer "${layer.name}" data length must equal width * height.`); + } + + const sourceData: ReadonlyArray = layer.data; + const data = new Uint32Array(width * height); + for (let i = 0; i < sourceData.length; i += 1) { + const value = sourceData[i]; + if (value === undefined || !Number.isInteger(value)) { + throw new Error(`layer "${layer.name}" tile at index ${i} must be an integer.`); + } + data[i] = value; + } + + let collision: boolean[] | undefined; + if (layer.collision) { + if (!Array.isArray(layer.collision) || layer.collision.length !== width * height) { + throw new Error(`layer "${layer.name}" collision array must match map size.`); + } + collision = layer.collision.map((value) => Boolean(value)); + } + + return { name: layer.name, data, collision }; +} + +function normalizeOptions(options: TileMapOptions): InternalOptions { + assertPositiveInt(options.width, 'width'); + assertPositiveInt(options.height, 'height'); + assertPositiveInt(options.tileWidth, 'tileWidth'); + assertPositiveInt(options.tileHeight, 'tileHeight'); + + const chunkWidth = options.chunkWidth ?? 16; + const chunkHeight = options.chunkHeight ?? 16; + assertPositiveInt(chunkWidth, 'chunkWidth'); + assertPositiveInt(chunkHeight, 'chunkHeight'); + + if (!Array.isArray(options.layers) || options.layers.length === 0) { + throw new Error('layers must contain at least one layer.'); + } + + const layers: NormalizedLayer[] = options.layers.map((layer: TileMapLayer) => + normalizeLayer(layer, options.width, options.height) + ); + + return { + width: options.width, + height: options.height, + tileWidth: options.tileWidth, + tileHeight: options.tileHeight, + chunkWidth, + chunkHeight, + layers, + }; +} + +function toIndex(width: number, x: number, y: number): number { + if (!Number.isInteger(x) || !Number.isInteger(y)) { + throw new Error('tile coordinates must be integers.'); + } + return y * width + x; +} + +function clampToBounds(value: number, min: number, max: number): number { + if (value < min) return min; + if (value > max) return max; + return value; +} + +function floorDivide(a: number, b: number): number { + return Math.floor(a / b); +} + +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/** + * Creates helpers for rendering tile maps in chunks with collision querying. + */ +export function createTileMapController(options: TileMapOptions): TileMapController { + const config = normalizeOptions(options); + + const layerLookup: Record = {}; + for (const layer of config.layers) { + if (layerLookup[layer.name]) { + throw new Error(`Duplicate layer name: ${layer.name}`); + } + layerLookup[layer.name] = layer; + } + + function assertInBounds(x: number, y: number): void { + if (x < 0 || y < 0 || x >= config.width || y >= config.height) { + throw new Error(`Tile coordinate (${x}, ${y}) out of bounds.`); + } + } + + function getLayer(name: string): NormalizedLayer { + const layer = layerLookup[name]; + if (!layer) { + throw new Error(`Unknown layer: ${name}`); + } + return layer; + } + + function getTile(layerName: string, x: number, y: number): number { + assertInBounds(x, y); + const layer = getLayer(layerName); + const index = toIndex(config.width, x, y); + const tileId = Number(layer.data[index]); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- tile data coerced to number. + return tileId; + } + + function setTile(layerName: string, x: number, y: number, tileId: number): void { + assertInBounds(x, y); + if (!Number.isInteger(tileId)) { + throw new Error('tileId must be an integer.'); + } + const layer = getLayer(layerName); + layer.data[toIndex(config.width, x, y)] = tileId; + } + + function isCollidable(x: number, y: number): boolean { + if (x < 0 || y < 0 || x >= config.width || y >= config.height) { + return false; + } + const index = toIndex(config.width, x, y); + return config.layers.some((layer) => layer.collision?.[index]); + } + + function getVisibleTiles(viewport: TileMapViewport): VisibleTile[] { + assertFinite(viewport.x, 'viewport.x'); + assertFinite(viewport.y, 'viewport.y'); + assertFinite(viewport.width, 'viewport.width'); + assertFinite(viewport.height, 'viewport.height'); + if (viewport.width <= 0 || viewport.height <= 0) { + throw new Error('viewport width/height must be positive.'); + } + + const startX = clampToBounds(Math.floor(viewport.x / config.tileWidth), 0, config.width - 1); + const startY = clampToBounds(Math.floor(viewport.y / config.tileHeight), 0, config.height - 1); + const endX = clampToBounds(Math.ceil((viewport.x + viewport.width) / config.tileWidth), 0, config.width); + const endY = clampToBounds(Math.ceil((viewport.y + viewport.height) / config.tileHeight), 0, config.height); + + const visible: VisibleTile[] = []; + + for (const layer of config.layers) { + for (let y = startY; y < endY; y += 1) { + for (let x = startX; x < endX; x += 1) { + const index = toIndex(config.width, x, y); + const tileId = layer.data[index]; + if (tileId === 0) { + continue; + } + visible.push({ + layer: layer.name, + tileIndex: index, + tileId, + mapX: x, + mapY: y, + worldX: x * config.tileWidth, + worldY: y * config.tileHeight, + }); + } + } + } + + return visible; + } + + function getVisibleChunks(viewport: TileMapViewport): ChunkCoordinate[] { + assertFinite(viewport.x, 'viewport.x'); + assertFinite(viewport.y, 'viewport.y'); + assertFinite(viewport.width, 'viewport.width'); + assertFinite(viewport.height, 'viewport.height'); + if (viewport.width <= 0 || viewport.height <= 0) { + throw new Error('viewport width/height must be positive.'); + } + + const tileStartX = clampToBounds(Math.floor(viewport.x / config.tileWidth), 0, config.width - 1); + const tileStartY = clampToBounds(Math.floor(viewport.y / config.tileHeight), 0, config.height - 1); + const tileEndX = clampToBounds(Math.ceil((viewport.x + viewport.width) / config.tileWidth), 0, config.width); + const tileEndY = clampToBounds(Math.ceil((viewport.y + viewport.height) / config.tileHeight), 0, config.height); + + const chunkStartX = floorDivide(tileStartX, config.chunkWidth); + const chunkStartY = floorDivide(tileStartY, config.chunkHeight); + const chunkEndX = floorDivide(Math.max(tileEndX - 1, tileStartX), config.chunkWidth); + const chunkEndY = floorDivide(Math.max(tileEndY - 1, tileStartY), config.chunkHeight); + + const result: ChunkCoordinate[] = []; + for (let cy = chunkStartY; cy <= chunkEndY; cy += 1) { + for (let cx = chunkStartX; cx <= chunkEndX; cx += 1) { + result.push({ cx, cy }); + } + } + return result; + } + + function getChunkSize(): Vector2D { + return { x: config.chunkWidth, y: config.chunkHeight }; + } + + return { + getTile, + setTile, + isCollidable, + getVisibleTiles, + getVisibleChunks, + getChunkSize, + }; +} + +/* eslint-enable @typescript-eslint/no-unsafe-return */ + +/** @internal */ +export const __internals = { + normalizeOptions, + normalizeLayer, + toIndex, +}; diff --git a/src/index.ts b/src/index.ts index e15be3b..958481f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -98,6 +98,7 @@ export const examples = { createTweenSystem: 'examples/tween.ts', createPlatformerController: 'examples/platformerPhysics.ts', createTopDownController: 'examples/topDownMovement.ts', + createTileMapController: 'examples/tileMap.ts', }, ai: { seek: 'examples/steering.ts', @@ -589,6 +590,25 @@ export type { TopDownUpdateOptions, } from './gameplay/topDownMovement.js'; +/** + * Tile map controller for chunked rendering and collision queries. + * + * Example file: examples/tileMap.ts + */ +export { createTileMapController } from './gameplay/tileMap.js'; + +/** + * Tile map configuration, layers, and visibility types. + */ +export type { + TileMapOptions, + TileMapLayer, + TileMapViewport, + TileMapController, + VisibleTile, + ChunkCoordinate, +} from './gameplay/tileMap.js'; + // ============================================================================ // 🔍 SEARCH & STRING UTILITIES diff --git a/tests/index.test.ts b/tests/index.test.ts index 8dc003d..dfb22da 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -32,6 +32,7 @@ describe('package entry point', () => { expect(examples.gameplay.createTweenSystem).toBe('examples/tween.ts'); expect(examples.gameplay.createPlatformerController).toBe('examples/platformerPhysics.ts'); expect(examples.gameplay.createTopDownController).toBe('examples/topDownMovement.ts'); + expect(examples.gameplay.createTileMapController).toBe('examples/tileMap.ts'); expect(examples.search.Trie).toBe('examples/search.ts'); expect(examples.pathfinding.buildNavMesh).toBe('examples/navMesh.ts'); }); @@ -107,6 +108,7 @@ describe('package entry point', () => { | 'createTweenSystem' | 'createPlatformerController' | 'createTopDownController' + | 'createTileMapController' >(); }); }); diff --git a/tests/tileMap.test.ts b/tests/tileMap.test.ts new file mode 100644 index 0000000..d2ae536 --- /dev/null +++ b/tests/tileMap.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { createTileMapController } from '../src/index.js'; + +describe('createTileMapController', () => { + const width = 4; + const height = 3; + const layerData = [ + 1, 0, 0, 2, + 0, 0, 3, 0, + 4, 0, 0, 0, + ]; + + const controller = createTileMapController({ + width, + height, + tileWidth: 16, + tileHeight: 16, + chunkWidth: 2, + chunkHeight: 2, + layers: [ + { + name: 'ground', + data: layerData, + collision: layerData.map((value) => value === 3), + }, + { + name: 'decor', + data: Array(width * height).fill(0), + }, + ], + }); + + it('reads and writes tile values', () => { + expect(controller.getTile('ground', 0, 0)).toBe(1); + controller.setTile('ground', 1, 1, 5); + expect(controller.getTile('ground', 1, 1)).toBe(5); + }); + + it('detects collision across layers', () => { + expect(controller.isCollidable(2, 1)).toBe(true); + expect(controller.isCollidable(0, 0)).toBe(false); + expect(controller.isCollidable(-1, 0)).toBe(false); + }); + + it('computes visible tiles within viewport', () => { + const tiles = controller.getVisibleTiles({ x: 0, y: 0, width: 32, height: 32 }); + const ids = tiles.map((tile) => tile.tileId); + expect(ids).toEqual([1, 5]); + }); + + it('computes visible chunks overlapping viewport', () => { + const chunks = controller.getVisibleChunks({ x: 0, y: 0, width: 64, height: 48 }); + expect(chunks).toEqual([ + { cx: 0, cy: 0 }, + { cx: 1, cy: 0 }, + { cx: 0, cy: 1 }, + { cx: 1, cy: 1 }, + ]); + }); +});