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:
parent
872adfe57d
commit
9072924c3f
3 changed files with 92 additions and 3 deletions
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
24
wolf3d-editor/app/test/zip.test.js
Normal file
24
wolf3d-editor/app/test/zip.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue