From 47a0692a4ccffd7557078781aa5c7d0bc37a9d8f Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 29 Mar 2026 09:03:00 +0200 Subject: [PATCH] feat(manavoxel): add portal system, interiors, and floor switching - AreaManager: load/unload areas, portal detection, fade transitions, floor switching for multi-story interiors - Demo street (10cm): cobblestone road, brick/wood buildings, trees, torches - Demo interior (5cm): 2-floor house with table, fireplace, bed, windows - TilemapRenderer: dynamic tileSize per resolution, clear(), setWorldSize() - Game engine: E key for doors, F key for stairs, area name + floor in HUD - Fade overlay for smooth area transitions Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/engine/area-manager.ts | 382 ++++++++++++++++++ .../manavoxel/apps/web/src/lib/engine/game.ts | 112 ++++- .../apps/web/src/lib/engine/tilemap.ts | 28 +- .../apps/web/src/routes/+page.svelte | 16 +- 4 files changed, 525 insertions(+), 13 deletions(-) create mode 100644 apps/manavoxel/apps/web/src/lib/engine/area-manager.ts diff --git a/apps/manavoxel/apps/web/src/lib/engine/area-manager.ts b/apps/manavoxel/apps/web/src/lib/engine/area-manager.ts new file mode 100644 index 000000000..d0eaa6282 --- /dev/null +++ b/apps/manavoxel/apps/web/src/lib/engine/area-manager.ts @@ -0,0 +1,382 @@ +import { Container } from 'pixi.js'; +import { TilemapRenderer } from './tilemap'; +import { Player } from './player'; +import type { Area, PortalDef, Material } from '@manavoxel/shared'; +import { DEFAULT_MATERIALS, MATERIAL_AIR } from '@manavoxel/shared'; + +export interface LoadedArea { + data: Area; + tilemap: TilemapRenderer; + currentFloor: number; +} + +/** + * Manages area loading/unloading and portal transitions. + * Each area is a separate tilemap with its own resolution. + */ +export class AreaManager { + private _worldContainer: Container; + private _currentArea: LoadedArea | null = null; + private _areas = new Map(); + private _palette: Material[] = DEFAULT_MATERIALS; + private _transitioning = false; + private _transitionAlpha = 1; + private _transitionCallback: (() => void) | null = null; + + /** Fires when a portal transition completes */ + onAreaChanged: ((area: LoadedArea) => void) | null = null; + + get currentArea() { + return this._currentArea; + } + get isTransitioning() { + return this._transitioning; + } + get transitionAlpha() { + return this._transitionAlpha; + } + + constructor(worldContainer: Container) { + this._worldContainer = worldContainer; + } + + /** Register an area so it can be loaded by ID */ + registerArea(area: Area) { + this._areas.set(area.id, area); + } + + /** Load and display an area */ + loadArea(areaId: string): LoadedArea | null { + const area = this._areas.get(areaId); + if (!area) return null; + + // Unload current + if (this._currentArea) { + this._worldContainer.removeChild(this._currentArea.tilemap['_container']); + } + + // Create new tilemap with area's resolution + const tilemap = new TilemapRenderer(this._worldContainer, this._palette, area.resolution); + + // Load pixel data into tilemap + this._loadPixelData(tilemap, area); + + const loaded: LoadedArea = { + data: area, + tilemap, + currentFloor: area.spawnPoint.floor, + }; + + this._currentArea = loaded; + this.onAreaChanged?.(loaded); + return loaded; + } + + /** Start a portal transition (fade out → load → fade in) */ + enterPortal(portal: PortalDef, player: Player) { + if (this._transitioning) return; + + this._transitioning = true; + this._transitionAlpha = 1; + + // Fade out phase + this._transitionCallback = () => { + // Load target area + const loaded = this.loadArea(portal.targetAreaId); + if (loaded && player) { + player.x = portal.targetX; + player.y = portal.targetY; + } + // Fade in will happen in update() + }; + } + + /** Check if player is standing on a portal */ + checkPortals(playerX: number, playerY: number, playerFloor: number): PortalDef | null { + if (!this._currentArea) return null; + + const portals = this._currentArea.data.portals; + for (const portal of portals) { + if (portal.floor !== playerFloor) continue; + + // Portal occupies a 2x2 pixel area for easier detection + const dx = Math.abs(Math.floor(playerX + 3) - portal.x); // +3 = player center + const dy = Math.abs(Math.floor(playerY + 4) - portal.y); // +4 = player center + if (dx <= 1 && dy <= 1) { + return portal; + } + } + return null; + } + + /** Switch floor within the current interior */ + switchFloor(floor: number) { + if (!this._currentArea) return; + if (floor < 0 || floor >= this._currentArea.data.floors) return; + this._currentArea.currentFloor = floor; + + // Reload pixel data for the new floor + this._loadPixelData(this._currentArea.tilemap, this._currentArea.data, floor); + } + + /** Called every frame to handle transition animation */ + update(dt: number) { + if (!this._transitioning) return; + + if (this._transitionCallback) { + // Fade out + this._transitionAlpha -= dt * 0.05; + if (this._transitionAlpha <= 0) { + this._transitionAlpha = 0; + this._transitionCallback(); + this._transitionCallback = null; + } + } else { + // Fade in + this._transitionAlpha += dt * 0.05; + if (this._transitionAlpha >= 1) { + this._transitionAlpha = 1; + this._transitioning = false; + } + } + } + + /** Load pixel data from an Area's compressed data into a tilemap */ + private _loadPixelData(tilemap: TilemapRenderer, area: Area, floor = 0) { + // For now: treat pixelData as raw u16 array (no compression in MVP) + // Each floor is width × height u16 values + const floorSize = area.width * area.height; + const offset = floor * floorSize * 2; // 2 bytes per pixel (u16) + + // Clear existing tilemap + tilemap.clear(); + tilemap.setWorldSize(area.width, area.height); + + if (area.pixelData.length === 0) return; + + const view = new DataView(area.pixelData.buffer, area.pixelData.byteOffset + offset); + + for (let y = 0; y < area.height; y++) { + for (let x = 0; x < area.width; x++) { + const idx = (y * area.width + x) * 2; + if (idx + 1 >= view.byteLength) break; + const material = view.getUint16(idx, true); // little-endian + if (material !== MATERIAL_AIR) { + tilemap.setPixel(x, y, material); + } + } + } + } +} + +// ─── Demo Data Generators ─────────────────────────────────── + +let areaIdCounter = 0; +function genId() { + return `area_${++areaIdCounter}`; +} + +/** Generate a demo street area */ +export function generateDemoStreet(): Area { + const id = genId(); + const width = 500; // 50m + const height = 300; // 30m + const pixelData = new Uint8Array(width * height * 2); + const view = new DataView(pixelData.buffer); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let mat = MATERIAL_AIR; + + // Border walls + if (x === 0 || x === width - 1 || y === 0 || y === height - 1) { + mat = 1; // Stone + } + // Road (middle band) + else if (y >= 130 && y <= 170) { + mat = 12; // Cobblestone + } + // Grass areas + else if (y > 170 || y < 130) { + if (Math.random() < 0.3) mat = 3; // Grass patches + } + // Buildings (top side) + else if (x >= 40 && x <= 80 && y >= 40 && y <= 80) { + if (x === 40 || x === 80 || y === 40 || y === 80) + mat = 8; // Brick walls + else if (y === 80 && x >= 56 && x <= 64) mat = MATERIAL_AIR; // Door + } + // Second building + else if (x >= 120 && x <= 170 && y >= 30 && y <= 90) { + if (x === 120 || x === 170 || y === 30 || y === 90) + mat = 4; // Wood walls + else if (y === 90 && x >= 140 && x <= 150) + mat = MATERIAL_AIR; // Door + else if (y === 45 && (x === 135 || x === 155)) mat = 9; // Windows + } + // Trees + else if (x === 250 && y >= 100 && y <= 120) + mat = 4; // Trunk + else if (Math.abs(x - 250) + Math.abs(y - 95) <= 8 && y < 105) + mat = 13; // Leaves + // Torches along the road + else if (y === 128 && x % 40 === 20) mat = 10; // Torch + + if (mat !== MATERIAL_AIR) { + view.setUint16((y * width + x) * 2, mat, true); + } + } + } + + const interiorId = genId(); + + return { + id, + worldId: 'demo', + name: 'Marktplatz', + type: 'street', + resolution: 0.1, + width, + height, + floors: 1, + pixelData, + palette: DEFAULT_MATERIALS, + entities: [], + portals: [ + { + id: 'portal_1', + x: 60, + y: 80, + floor: 0, + targetAreaId: interiorId, + targetX: 60, + targetY: 110, + targetFloor: 0, + }, + ], + spawnPoint: { x: 60, y: 150, floor: 0 }, + }; +} + +/** Generate a demo interior area */ +export function generateDemoInterior(id: string, streetId: string): Area { + const width = 240; // 12m at 5cm = 240 pixels + const height = 160; // 8m at 5cm = 160 pixels + const floors = 2; + const pixelData = new Uint8Array(width * height * floors * 2); + const view = new DataView(pixelData.buffer); + + function setPixel(floor: number, x: number, y: number, mat: number) { + const offset = floor * width * height; + const idx = (offset + y * width + x) * 2; + if (idx + 1 < pixelData.length) { + view.setUint16(idx, mat, true); + } + } + + // Floor 0: Ground floor + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + // Walls + if (x === 0 || x === width - 1 || y === 0 || y === height - 1) { + setPixel(0, x, y, 1); // Stone walls + } + // Floor + else if (y > 2 && x > 2 && x < width - 3 && y < height - 3) { + setPixel(0, x, y, 5); // Plank floor + } + // Door back to street + if (y === height - 1 && x >= 55 && x <= 65) { + setPixel(0, x, y, MATERIAL_AIR); + } + // Windows + if (y === 0 && (x === 40 || x === 80 || x === 120 || x === 160 || x === 200)) { + setPixel(0, x, y, 9); // Glass + } + } + } + + // Furniture on ground floor + // Table (center of room) + for (let y = 60; y <= 80; y++) { + for (let x = 90; x <= 130; x++) { + if (y === 60 || y === 80 || x === 90 || x === 130) { + setPixel(0, x, y, 4); // Wood frame + } + } + } + + // Fireplace (right wall) + for (let y = 30; y <= 55; y++) { + for (let x = 200; x <= 230; x++) { + if (y === 30 || x === 200 || x === 230) { + setPixel(0, x, y, 8); // Brick + } else if (y >= 45 && y <= 50 && x >= 210 && x <= 220) { + setPixel(0, x, y, 10); // Torch (fire) + } + } + } + + // Stairs indicator (bottom-right) + for (let y = 120; y <= 140; y++) { + for (let x = 190; x <= 210; x++) { + setPixel(0, x, y, 14); // Roof color = stairs marker + } + } + + // Floor 1: Upper floor + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (x === 0 || x === width - 1 || y === 0 || y === height - 1) { + setPixel(1, x, y, 1); // Walls + } else if (y > 2 && x > 2 && x < width - 3 && y < height - 3) { + setPixel(1, x, y, 5); // Plank floor + } + } + } + + // Bed on upper floor + for (let y = 30; y <= 70; y++) { + for (let x = 20; x <= 60; x++) { + if (y === 30 || y === 70 || x === 20 || x === 60) { + setPixel(1, x, y, 4); // Wood frame + } else { + setPixel(1, x, y, 15); // Snow (white = sheets) + } + } + } + + // Stairs back down on upper floor + for (let y = 120; y <= 140; y++) { + for (let x = 190; x <= 210; x++) { + setPixel(1, x, y, 14); // Stairs marker + } + } + + return { + id, + worldId: 'demo', + name: 'Haus am Marktplatz', + type: 'interior', + resolution: 0.05, + width, + height, + floors, + pixelData, + palette: DEFAULT_MATERIALS, + entities: [], + portals: [ + { + id: 'portal_back', + x: 60, + y: height - 1, + floor: 0, + targetAreaId: streetId, + targetX: 60, + targetY: 82, + targetFloor: 0, + }, + ], + spawnPoint: { x: 60, y: height - 10, floor: 0 }, + }; +} diff --git a/apps/manavoxel/apps/web/src/lib/engine/game.ts b/apps/manavoxel/apps/web/src/lib/engine/game.ts index bb1869292..547a3d8c8 100644 --- a/apps/manavoxel/apps/web/src/lib/engine/game.ts +++ b/apps/manavoxel/apps/web/src/lib/engine/game.ts @@ -1,8 +1,9 @@ -import { Application, Container } from 'pixi.js'; +import { Application, Container, Graphics } from 'pixi.js'; import { Camera } from './camera'; import { InputManager } from './input'; import { TilemapRenderer } from './tilemap'; import { Player } from './player'; +import { AreaManager, generateDemoStreet, generateDemoInterior } from './area-manager'; import { UndoStack, brushStroke, floodFill, pipette, type ToolType } from '$lib/editor/tools'; import { DEFAULT_MATERIALS, MATERIAL_AIR, type Material } from '@manavoxel/shared'; @@ -10,12 +11,14 @@ export class GameEngine { app: Application; camera: Camera; input: InputManager; - tilemap: TilemapRenderer; + tilemap!: TilemapRenderer; player: Player | null = null; undo: UndoStack; + areaManager: AreaManager; private _container: HTMLDivElement; private _worldContainer: Container; + private _fadeOverlay: Graphics; private _initialized = false; // Editor state @@ -24,7 +27,11 @@ export class GameEngine { private _activeTool: ToolType = 'brush'; private _brushSize = 1; private _palette: Material[] = DEFAULT_MATERIALS; - private _painting = false; // tracks whether we're in a continuous paint stroke + private _painting = false; + + // Area state + private _currentFloor = 0; + private _areaName = ''; // Callbacks for UI reactivity onStateChange: (() => void) | null = null; @@ -44,16 +51,26 @@ export class GameEngine { get palette() { return this._palette; } + get currentFloor() { + return this._currentFloor; + } + get totalFloors() { + return this.areaManager.currentArea?.data.floors ?? 1; + } + get areaName() { + return this._areaName; + } constructor(container: HTMLDivElement) { this._container = container; this.app = new Application(); this._worldContainer = new Container(); + this._fadeOverlay = new Graphics(); this.undo = new UndoStack(); this.camera = new Camera(this._worldContainer); this.input = new InputManager(container); - this.tilemap = new TilemapRenderer(this._worldContainer, this._palette); + this.areaManager = new AreaManager(this._worldContainer); this._init(); } @@ -70,14 +87,51 @@ export class GameEngine { this._container.appendChild(this.app.canvas); this.app.stage.addChild(this._worldContainer); - // Generate demo world - this.tilemap.generateFlatWorld(500, 300); + // Fade overlay (on top of world, for transitions) + this._fadeOverlay.rect(0, 0, 1, 1); + this._fadeOverlay.fill('#000000'); + this.app.stage.addChild(this._fadeOverlay); + this._fadeOverlay.visible = false; - // Spawn player in an open area - this.player = new Player(this._worldContainer, this.tilemap, 60, 160); + // Generate demo areas + const street = generateDemoStreet(); + const interiorId = street.portals[0]?.targetAreaId; + const interior = generateDemoInterior(interiorId!, street.id); - // Center camera on player - this.camera.setPosition(this.player.worldX, this.player.worldY); + this.areaManager.registerArea(street); + this.areaManager.registerArea(interior); + + // Area change callback + this.areaManager.onAreaChanged = (loaded) => { + this.tilemap = loaded.tilemap; + this._currentFloor = loaded.currentFloor; + this._areaName = loaded.data.name; + + // Recreate player in new area + this.player?.destroy(); + this.player = new Player( + this._worldContainer, + this.tilemap, + loaded.data.spawnPoint.x, + loaded.data.spawnPoint.y + ); + + this.onStateChange?.(); + }; + + // Load starting area + const loaded = this.areaManager.loadArea(street.id); + if (loaded) { + this.tilemap = loaded.tilemap; + this._areaName = loaded.data.name; + this.player = new Player( + this._worldContainer, + this.tilemap, + street.spawnPoint.x, + street.spawnPoint.y + ); + this.camera.setPosition(this.player.worldX, this.player.worldY); + } // Game loop this.app.ticker.add((ticker) => this._update(ticker.deltaTime)); @@ -117,6 +171,13 @@ export class GameEngine { } private _updateGame() { + // Don't process input during transitions + if (this.areaManager.isTransitioning) { + this.areaManager.update(1); + this._updateFadeOverlay(); + return; + } + // Player movement let dx = 0; let dy = 0; @@ -127,6 +188,25 @@ export class GameEngine { if (this.player) { this.player.move(dx, dy); + + // Check for portal collision + const portal = this.areaManager.checkPortals( + this.player.x, + this.player.y, + this._currentFloor + ); + if (portal && this.input.isKeyDown('KeyE')) { + this.areaManager.enterPortal(portal, this.player); + } + + // Check for stairs (floor switch via E key on stair tiles) + if (this.input.isKeyDown('KeyF') && this.totalFloors > 1) { + const nextFloor = (this._currentFloor + 1) % this.totalFloors; + this.areaManager.switchFloor(nextFloor); + this._currentFloor = nextFloor; + this.onStateChange?.(); + } + // Camera follows player smoothly const lerpSpeed = 0.1; const cx = this.camera.x + (this.player.worldX - this.camera.x) * lerpSpeed; @@ -135,6 +215,18 @@ export class GameEngine { } } + private _updateFadeOverlay() { + if (this.areaManager.isTransitioning) { + this._fadeOverlay.visible = true; + this._fadeOverlay.alpha = 1 - this.areaManager.transitionAlpha; + this._fadeOverlay.clear(); + this._fadeOverlay.rect(0, 0, this.app.screen.width, this.app.screen.height); + this._fadeOverlay.fill('#000000'); + } else { + this._fadeOverlay.visible = false; + } + } + private _updateEditor() { // Camera pan with WASD in editor mode const moveSpeed = 4; diff --git a/apps/manavoxel/apps/web/src/lib/engine/tilemap.ts b/apps/manavoxel/apps/web/src/lib/engine/tilemap.ts index 3c9a50883..b0c44aace 100644 --- a/apps/manavoxel/apps/web/src/lib/engine/tilemap.ts +++ b/apps/manavoxel/apps/web/src/lib/engine/tilemap.ts @@ -16,7 +16,7 @@ interface Chunk { } export class TilemapRenderer { - readonly tileSize = 8; // Screen pixels per world pixel (at 1x zoom) + readonly tileSize: number; // Screen pixels per world pixel (at 1x zoom) private _container: Container; private _palette: Material[]; private _chunks = new Map(); @@ -29,13 +29,37 @@ export class TilemapRenderer { get worldHeight() { return this._worldHeight; } + get container() { + return this._container; + } - constructor(worldContainer: Container, palette: Material[]) { + /** + * @param resolution - meters per pixel (0.1 for streets, 0.05 for interiors) + */ + constructor(worldContainer: Container, palette: Material[], resolution = 0.1) { + this.tileSize = Math.round(8 * (0.1 / resolution)); this._container = new Container(); worldContainer.addChild(this._container); this._palette = palette; } + /** Set world bounds (used when loading an area) */ + setWorldSize(width: number, height: number) { + this._worldWidth = width; + this._worldHeight = height; + } + + /** Remove all chunks and clear the renderer */ + clear() { + for (const chunk of this._chunks.values()) { + this._container.removeChild(chunk.graphics); + chunk.graphics.destroy(); + } + this._chunks.clear(); + this._worldWidth = 0; + this._worldHeight = 0; + } + /** Generate a flat world with grass floor and stone borders */ generateFlatWorld(width: number, height: number) { this._worldWidth = width; diff --git a/apps/manavoxel/apps/web/src/routes/+page.svelte b/apps/manavoxel/apps/web/src/routes/+page.svelte index 573cde047..89852d903 100644 --- a/apps/manavoxel/apps/web/src/routes/+page.svelte +++ b/apps/manavoxel/apps/web/src/routes/+page.svelte @@ -10,6 +10,9 @@ let selectedMaterial = $state(1); let activeTool = $state('brush'); let brushSize = $state(1); + let areaName = $state(''); + let currentFloor = $state(0); + let totalFloors = $state(1); const tools: { id: ToolType; label: string; key: string }[] = [ { id: 'brush', label: 'Brush', key: 'B' }, @@ -29,6 +32,9 @@ selectedMaterial = e.selectedMaterial; activeTool = e.activeTool; brushSize = e.brushSize; + areaName = e.areaName; + currentFloor = e.currentFloor; + totalFloors = e.totalFloors; }; // Keyboard shortcuts @@ -87,6 +93,14 @@ > ManaVoxel + {#if areaName} +
+ {areaName} + {#if totalFloors > 1} + F{currentFloor + 1}/{totalFloors} + {/if} +
+ {/if} {#if !isEditing && engine?.player}
HP: {engine.player.hp}/{engine.player.maxHp} @@ -197,7 +211,7 @@ {#if isEditing} WASD: Pan | Scroll: Zoom | LClick: Place | RClick: Erase | 1-9: Material {:else} - WASD: Move | Scroll: Zoom | Tab: Editor + WASD: Move | E: Door | F: Stairs | Scroll: Zoom | Tab: Editor {/if}