1
0
Fork 0
mirror of https://github.com/imjasonh/terraform-playground synced 2026-07-18 06:59:33 +00:00

wolf3d-editor: level create/delete, ZIP round-trip test, PLAN header-size fix

Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-06-12 23:09:38 +00:00
parent 872adfe57d
commit 9072924c3f
No known key found for this signature in database
3 changed files with 92 additions and 3 deletions

View file

@ -1,6 +1,6 @@
import React from 'react';
import { levelLabel, LIMITS } from '@wolf3d/data';
import { store, currentLevel, updateUi } from '../store.js';
import { store, currentLevel, updateUi, createLevel, deleteLevel } from '../store.js';
import { useStoreVersion } from '../hooks.js';
import { levelStats } from '../game/stats.js';
import { floorCodeColor } from '../game/assets.js';
@ -39,8 +39,18 @@ export function SidePanel() {
minHeight: 0,
}}
>
<div style={{ color: 'var(--dim)', fontSize: 11, padding: '8px 8px 4px' }}>
<div style={{ color: 'var(--dim)', fontSize: 11, padding: '8px 8px 4px', display: 'flex', alignItems: 'center' }}>
LEVELS ({game.levels.filter(Boolean).length} in {game.ext.toUpperCase()})
<button
style={{ marginLeft: 'auto', padding: '0 6px', fontSize: 11 }}
title="Create a level in the first empty slot"
onClick={() => {
const slot = game.levels.findIndex((l) => !l);
if (slot >= 0) createLevel(slot);
}}
>
+ new
</button>
</div>
<div style={{ overflowY: 'auto', maxHeight: '38%', borderBottom: '1px solid var(--border)' }}>
{game.levels.map((l, i) =>
@ -60,10 +70,24 @@ export function SidePanel() {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'flex',
alignItems: 'center',
}}
>
<span style={{ opacity: 0.6, marginRight: 6 }}>{levelLabel(i)}</span>
{l.name || '(unnamed)'}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{l.name || '(unnamed)'}</span>
{store.ui.level === i && (
<span
title="Delete this level"
style={{ marginLeft: 'auto', cursor: 'pointer', padding: '0 4px' }}
onClick={(e) => {
e.stopPropagation();
if (window.confirm(`Delete ${levelLabel(i)} "${l.name}"?`)) deleteLevel(i);
}}
>
</span>
)}
</div>
) : null,
)}

View file

@ -247,6 +247,47 @@ export function setSpritePixels(num, pixels) {
notify();
}
/**
* Create a fresh level in an empty slot: solid grey-stone border, floor code
* 6C inside, player start in the middle (the engine requires exactly one).
* @param {number} slot
*/
export function createLevel(slot) {
const game = store.game;
if (!game || game.levels[slot]) return;
const plane0 = new Uint16Array(MAP_WIDTH * MAP_WIDTH);
const plane1 = new Uint16Array(MAP_WIDTH * MAP_WIDTH);
for (let y = 0; y < MAP_WIDTH; y++) {
for (let x = 0; x < MAP_WIDTH; x++) {
const border = x === 0 || y === 0 || x === MAP_WIDTH - 1 || y === MAP_WIDTH - 1;
plane0[y * MAP_WIDTH + x] = border ? 1 : 0x6c;
}
}
plane1[32 * MAP_WIDTH + 32] = 19; // player start facing north
game.levels[slot] = { name: `New Map ${slot + 1}`, plane0, plane1, plane2: null };
store.ui.level = slot;
store.ui.dirty = true;
notify();
}
/**
* Remove a level from its slot.
* @param {number} slot
*/
export function deleteLevel(slot) {
const game = store.game;
if (!game || !game.levels[slot]) return;
game.levels[slot] = null;
store.undoStack = store.undoStack.filter((r) => r.level !== slot);
store.redoStack = store.redoStack.filter((r) => r.level !== slot);
if (store.ui.level === slot) {
const next = game.levels.findIndex(Boolean);
store.ui.level = next >= 0 ? next : 0;
}
store.ui.dirty = true;
notify();
}
/** Mutate UI state and notify. @param {(ui: typeof store.ui) => void} fn */
export function updateUi(fn) {
fn(store.ui);

View file

@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { buildZip, readZip, crc32 } from '../src/io/zip.js';
describe('zip container', () => {
it('round-trips entries', async () => {
const entries = [
{ name: 'GAMEMAPS.WL6', data: Uint8Array.from({ length: 5000 }, (_, i) => (i * 7) & 0xff) },
{ name: 'MAPHEAD.WL6', data: Uint8Array.from({ length: 402 }, (_, i) => i & 0xff) },
{ name: 'dir/readme.txt', data: new TextEncoder().encode('hello wolf') },
];
const zip = buildZip(entries);
const back = await readZip(zip);
expect(back).toHaveLength(3);
for (let i = 0; i < entries.length; i++) {
expect(back[i].name).toBe(entries[i].name);
expect(back[i].data).toEqual(entries[i].data);
}
});
it('computes standard CRC32', () => {
// CRC32 of "123456789" is 0xCBF43926 (the canonical check value).
expect(crc32(new TextEncoder().encode('123456789'))).toBe(0xcbf43926);
});
});