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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-29 09:03:00 +02:00
parent 5589765180
commit 47a0692a4c
4 changed files with 525 additions and 13 deletions

View file

@ -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<string, Area>();
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 },
};
}

View file

@ -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;

View file

@ -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<string, Chunk>();
@ -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;

View file

@ -10,6 +10,9 @@
let selectedMaterial = $state(1);
let activeTool = $state<ToolType>('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
</div>
{#if areaName}
<div class="rounded-lg bg-gray-800/80 px-3 py-1.5 text-xs text-gray-300 backdrop-blur">
{areaName}
{#if totalFloors > 1}
<span class="ml-1 text-gray-500">F{currentFloor + 1}/{totalFloors}</span>
{/if}
</div>
{/if}
{#if !isEditing && engine?.player}
<div class="rounded-lg bg-gray-800/80 px-3 py-1.5 text-xs text-gray-300 backdrop-blur">
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}
</div>
</div>