import { FloorplanAction, FloorplanState, Tile } from './types'; import { MAX_NUM_TILE_PER_AXIS, EMPTY_DOOR, MIN_WALL_HEIGHT, MAX_WALL_HEIGHT } from './constants'; import { parseTilemap } from './encoding'; export const initialState: FloorplanState = { tiles: [], door: { ...EMPTY_DOOR }, thickness: { wall: 1, floor: 1 }, wallHeight: -1, brush: { h: 0, action: 'SET' }, selection: new Set<`${number},${number}`>(), squareSelect: false, lease: { holder: null, me: false, expiresAt: null }, seq: 0 }; const clampHeight = (h: number): number => Math.max(0, Math.min(26, h | 0)); const clamp64 = (n: number): number => Math.max(0, Math.min(MAX_NUM_TILE_PER_AXIS - 1, n | 0)); const ensureRect = (tiles: Tile[][], rows: number, cols: number): Tile[][] => { const tRows = Math.min(MAX_NUM_TILE_PER_AXIS, Math.max(rows, tiles.length)); const tCols = Math.min(MAX_NUM_TILE_PER_AXIS, Math.max(cols, tiles[0]?.length ?? 0)); if(tRows === tiles.length && (tiles[0]?.length ?? 0) === tCols) return tiles; const next: Tile[][] = []; for(let r = 0; r < tRows; r++) { const src = tiles[r] ?? []; const row: Tile[] = []; for(let c = 0; c < tCols; c++) { row.push(src[c] ?? { h: 0, blocked: true }); } next.push(row); } return next; }; const setTile = (tiles: Tile[][], row: number, col: number, tile: Tile): Tile[][] => { const current = tiles[row]?.[col]; if(current && current.h === tile.h && current.blocked === tile.blocked) return tiles; const next = tiles.map((r, ri) => ri === row ? r.map((t, ci) => ci === col ? tile : t) : r); return next; }; export const reducer = (state: FloorplanState, action: FloorplanAction): FloorplanState => { switch(action.type) { case 'PAINT_TILE': { const row = clamp64(action.row); const col = clamp64(action.col); const tiles = ensureRect(state.tiles, row + 1, col + 1); const target = { h: clampHeight(action.h), blocked: false }; const next = setTile(tiles, row, col, target); if(next === tiles && tiles === state.tiles) return state; return { ...state, tiles: next }; } case 'ERASE_TILE': { const row = action.row | 0; const col = action.col | 0; if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state; const current = state.tiles[row][col]; const target = { h: current.h, blocked: true }; const next = setTile(state.tiles, row, col, target); if(next === state.tiles) return state; return { ...state, tiles: next }; } case 'ADJUST_HEIGHT': { const row = action.row | 0; const col = action.col | 0; if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state; const current = state.tiles[row][col]; if(current.blocked) return state; const newH = clampHeight(current.h + action.delta); if(newH === current.h) return state; const next = setTile(state.tiles, row, col, { h: newH, blocked: false }); return { ...state, tiles: next }; } case 'SET_DOOR': { const x = clamp64(action.x); const y = clamp64(action.y); if(state.door.x === x && state.door.y === y) return state; return { ...state, door: { ...state.door, x, y } }; } case 'SET_DOOR_DIR': { if(state.door.dir === action.dir) return state; return { ...state, door: { ...state.door, dir: action.dir } }; } case 'SET_THICKNESS': { const wall = action.wall ?? state.thickness.wall; const floor = action.floor ?? state.thickness.floor; if(wall === state.thickness.wall && floor === state.thickness.floor) return state; return { ...state, thickness: { wall, floor } }; } case 'SET_WALL_HEIGHT': { const value = Math.max(MIN_WALL_HEIGHT, Math.min(MAX_WALL_HEIGHT, action.value | 0)); if(value === state.wallHeight) return state; return { ...state, wallHeight: value }; } case 'BRUSH_SET': { const h = action.h ?? state.brush.h; const act = action.action ?? state.brush.action; if(h === state.brush.h && act === state.brush.action) return state; return { ...state, brush: { h: clampHeight(h), action: act } }; } case 'SELECT_ALL': { const sel = new Set<`${number},${number}`>(); for(let r = 0; r < MAX_NUM_TILE_PER_AXIS; r++) { for(let c = 0; c < MAX_NUM_TILE_PER_AXIS; c++) { sel.add(`${r},${c}`); } } return { ...state, selection: sel }; } case 'CLEAR_SELECTION': return state.selection.size === 0 ? state : { ...state, selection: new Set() }; case 'SELECT_RECT': { const [ r0, c0 ] = action.from; const [ r1, c1 ] = action.to; const rMin = Math.max(0, Math.min(r0, r1)); const rMax = Math.min(MAX_NUM_TILE_PER_AXIS - 1, Math.max(r0, r1)); const cMin = Math.max(0, Math.min(c0, c1)); const cMax = Math.min(MAX_NUM_TILE_PER_AXIS - 1, Math.max(c0, c1)); const sel = new Set<`${number},${number}`>(); for(let r = rMin; r <= rMax; r++) { for(let c = cMin; c <= cMax; c++) { sel.add(`${r},${c}`); } } return { ...state, selection: sel }; } case 'APPLY_BRUSH_TO_SELECTION': { if(state.selection.size === 0) return state; if(state.brush.action === 'DOOR') return { ...state, selection: new Set() }; let maxR = -1; let maxC = -1; for(const key of state.selection) { const [ rStr, cStr ] = key.split(','); const r = parseInt(rStr, 10); const c = parseInt(cStr, 10); if(r > maxR) maxR = r; if(c > maxC) maxC = c; } let tiles = state.tiles; if(state.brush.action === 'SET' && maxR >= 0 && maxC >= 0) { tiles = ensureRect(tiles, maxR + 1, maxC + 1); } for(const key of state.selection) { const [ rStr, cStr ] = key.split(','); const row = parseInt(rStr, 10); const col = parseInt(cStr, 10); const current = tiles[row]?.[col]; if(!current) continue; switch(state.brush.action) { case 'SET': tiles = setTile(tiles, row, col, { h: clampHeight(state.brush.h), blocked: false }); break; case 'UNSET': if(current.blocked) continue; tiles = setTile(tiles, row, col, { h: current.h, blocked: true }); break; case 'UP': if(current.blocked) continue; tiles = setTile(tiles, row, col, { h: clampHeight(current.h + 1), blocked: false }); break; case 'DOWN': if(current.blocked) continue; tiles = setTile(tiles, row, col, { h: clampHeight(current.h - 1), blocked: false }); break; } } return { ...state, tiles, selection: new Set() }; } case 'SQUARE_SELECT_TOGGLE': return { ...state, squareSelect: !state.squareSelect }; case 'IMPORT_STRING': { const tiles = parseTilemap(action.raw); const next: FloorplanState = { ...state, tiles }; if(action.door) next.door = action.door; if(action.thickness) next.thickness = action.thickness; if(action.wallHeight !== undefined) next.wallHeight = Math.max(MIN_WALL_HEIGHT, Math.min(MAX_WALL_HEIGHT, action.wallHeight | 0)); return next; } case 'APPLY_REMOTE_DIFF': { let next: FloorplanState = { ...state, seq: action.seq }; if(action.diff.tiles) { let tiles = next.tiles; for(const e of action.diff.tiles) { tiles = ensureRect(tiles, e.row + 1, e.col + 1); tiles = setTile(tiles, e.row, e.col, { h: clampHeight(e.h), blocked: e.blocked }); } next.tiles = tiles; } if(action.diff.door) next.door = action.diff.door; if(action.diff.thickness) next.thickness = action.diff.thickness; if(action.diff.wallHeight !== undefined) next.wallHeight = Math.max(MIN_WALL_HEIGHT, Math.min(MAX_WALL_HEIGHT, action.diff.wallHeight | 0)); return next; } case 'APPLY_REMOTE_SNAPSHOT': { return { ...state, tiles: parseTilemap(action.raw), door: action.door, thickness: action.thickness, wallHeight: Math.max(MIN_WALL_HEIGHT, Math.min(MAX_WALL_HEIGHT, action.wallHeight | 0)), selection: new Set(), seq: action.seq }; } default: return state; } };