Skip to content

Commit 6525636

Browse files
committed
WIP custom game designer and custom pieces
1 parent 6d8b800 commit 6525636

16 files changed

Lines changed: 1819 additions & 110 deletions

RedGD.png

2.48 KB
Loading

src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { LeaderboardScreen } from "./screens/LeaderboardScreen";
88
import { SettingsScreen } from "./screens/SettingsScreen";
99
import { LearnScreen } from "./screens/LearnScreen";
1010
import { PuzzlesScreen } from "./screens/PuzzlesScreen";
11+
import { PieceDesignerScreen } from "./screens/PieceDesignerScreen";
12+
import { BoardDesignerScreen } from "./screens/BoardDesignerScreen";
1113

1214
export default function App() {
1315
return (
@@ -22,6 +24,8 @@ export default function App() {
2224
<Route path="/leaderboard" element={<LeaderboardScreen />} />
2325
<Route path="/learn" element={<LearnScreen />} />
2426
<Route path="/settings" element={<SettingsScreen />} />
27+
<Route path="/piece-designer" element={<PieceDesignerScreen />} />
28+
<Route path="/board-designer" element={<BoardDesignerScreen />} />
2529
</Routes>
2630
</HashRouter>
2731
</GameProvider>

src/GameContext.tsx

Lines changed: 113 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {
22
createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState
33
} from "react";
44
import {
5-
Color, GameState, Move, PieceType, Square, initialState, pieceAt
5+
Color, CustomPieceDef, GameState, Move, PieceType, Square, cloneState, initialState, pieceAt, positionKey
66
} from "./engine/board";
77
import {
88
forfeitMove, gameResult, inCheck, legalMovesFrom, makeMove
@@ -18,7 +18,7 @@ import {
1818
} from "./engine/performance";
1919
import { playSound } from "./engine/sound";
2020
import {
21-
load, Profile, recordResult, saveActiveGame, Settings, Store, updateSettings,
21+
load, Profile, recordResult, saveActiveGame, SavedBoardLayout, SavedCustomGame, Settings, Store, updateSettings,
2222
createProfile, deleteProfile, renameProfile,
2323
loadActiveSession, saveActiveSession, clearActiveSession,
2424
recordPuzzleSolved as storeRecordPuzzleSolved,
@@ -28,7 +28,8 @@ import {
2828
type Mode =
2929
| { kind: "two-player" }
3030
| { kind: "bot"; level: number }
31-
| { kind: "portal"; opponent: "two-player" | { kind: "bot"; level: number }; creator: PieceType; portalMax?: number };
31+
| { kind: "portal"; opponent: "two-player" | { kind: "bot"; level: number }; creator: PieceType; portalMax?: number }
32+
| { kind: "custom"; customPiece?: CustomPieceDef; opponent: "two-player" | { kind: "bot"; level: number } };
3233
export interface Players { w: string; b: string; }
3334

3435
interface RatedMoveFeedback extends MoveFeedback {
@@ -65,6 +66,8 @@ interface PlayerGamePerformance extends GamePerformanceSummary {
6566

6667
interface NewGameOptions {
6768
timerSeconds?: number;
69+
boardLayout?: SavedBoardLayout | null;
70+
customGame?: SavedCustomGame | null;
6871
}
6972

7073
/** Build the initial state for Portal Chess (creator-type portals). */
@@ -76,6 +79,72 @@ function portalInitialState(creator: PieceType, portalMax = 2): GameState {
7679
return s;
7780
}
7881

82+
/** Build an initial state from a saved board layout or custom game (white mirrored to black). */
83+
function customBoardState(layout: SavedBoardLayout | SavedCustomGame): GameState {
84+
const empty: (import("./engine/board").Piece | null)[][] =
85+
Array.from({ length: 8 }, () => Array(8).fill(null));
86+
const customPieces = "customPieces" in layout && Array.isArray(layout.customPieces)
87+
? Object.fromEntries(layout.customPieces.map((piece) => [piece.id, piece.def]))
88+
: undefined;
89+
const legacyCustomId = "legacy-x1";
90+
for (const square of layout.squares) {
91+
const { rank, file, type } = square;
92+
if (rank < 0 || rank > 3 || file < 0 || file > 7) continue;
93+
const customId =
94+
type === "X1"
95+
? (("customPieceId" in square && square.customPieceId) || (("customPieceDef" in layout && layout.customPieceDef) ? legacyCustomId : undefined))
96+
: undefined;
97+
empty[rank][file] = { type, color: "w", customId };
98+
empty[7 - rank][file] = { type, color: "b", customId };
99+
}
100+
const wKok = empty[0][4]?.type === "K" && empty[0][4]?.color === "w";
101+
const bKok = empty[7][4]?.type === "K" && empty[7][4]?.color === "b";
102+
const state: GameState = {
103+
board: empty,
104+
turn: "w",
105+
castling: {
106+
wK: wKok && empty[0][7]?.type === "R",
107+
wQ: wKok && empty[0][0]?.type === "R",
108+
bK: bKok && empty[7][7]?.type === "R",
109+
bQ: bKok && empty[7][0]?.type === "R"
110+
},
111+
enPassant: null,
112+
halfmove: 0,
113+
fullmove: 1,
114+
history: [],
115+
forfeits: [],
116+
positionKeys: []
117+
};
118+
if (customPieces && Object.keys(customPieces).length > 0) {
119+
state.customPieces = customPieces;
120+
} else if ("customPieceDef" in layout && layout.customPieceDef) {
121+
state.customPiece = layout.customPieceDef;
122+
state.customPieces = { [legacyCustomId]: layout.customPieceDef };
123+
}
124+
state.positionKeys.push(positionKey(state));
125+
return state;
126+
}
127+
128+
/** Swap all instances of `replaces` on the board to X1 and attach the def. */
129+
function withCustomPiece(state: GameState, def: CustomPieceDef, replaces: PieceType): GameState {
130+
const ns = cloneState(state);
131+
const legacyCustomId = "legacy-x1";
132+
ns.customPiece = def;
133+
ns.customPieces = { [legacyCustomId]: def };
134+
ns.replaces = replaces;
135+
for (let r = 0; r < 8; r++) {
136+
for (let f = 0; f < 8; f++) {
137+
const p = ns.board[r][f];
138+
if (p && p.type === replaces) {
139+
ns.board[r][f] = { type: "X1", color: p.color, customId: legacyCustomId };
140+
}
141+
}
142+
}
143+
// Recompute positionKey after board change
144+
ns.positionKeys = [positionKey(ns)];
145+
return ns;
146+
}
147+
79148
interface GameCtx {
80149
store: Store;
81150
activeProfile: Profile | null;
@@ -122,12 +191,15 @@ interface GameCtx {
122191
updateSetting<K extends keyof Settings>(key: K, value: Settings[K]): void;
123192
}
124193

125-
/** Returns the bot level if the mode is bot-driven (incl. portal+bot), else null. */
194+
/** Returns the bot level if the mode is bot-driven (incl. portal+bot, custom+bot), else null. */
126195
function botLevelOf(m: Mode): number | null {
127196
if (m.kind === "bot") return m.level;
128197
if (m.kind === "portal" && typeof m.opponent !== "string" && m.opponent.kind === "bot") {
129198
return m.opponent.level;
130199
}
200+
if (m.kind === "custom" && typeof m.opponent !== "string" && m.opponent.kind === "bot") {
201+
return m.opponent.level;
202+
}
131203
return null;
132204
}
133205

@@ -414,7 +486,8 @@ export function GameProvider({ children }: { children: ReactNode }) {
414486
? 600
415487
: 400;
416488
const t0 = performance.now();
417-
const move = await chooseBotMove(state, lvl, { allowExternal: mode.kind === "bot" });
489+
const hasCustomPieces = Boolean(state.customPiece || (state.customPieces && Object.keys(state.customPieces).length > 0));
490+
const move = await chooseBotMove(state, lvl, { allowExternal: mode.kind === "bot" && !hasCustomPieces });
418491
const elapsed = performance.now() - t0;
419492
if (elapsed < minThinkMs) {
420493
await new Promise((r) => setTimeout(r, minThinkMs - elapsed));
@@ -650,9 +723,36 @@ export function GameProvider({ children }: { children: ReactNode }) {
650723
? `Bot Lv ${m.level}`
651724
: m.kind === "portal" && typeof m.opponent !== "string" && m.opponent.kind === "bot"
652725
? `Bot Lv ${m.opponent.level}`
653-
: "Player 2";
726+
: m.kind === "custom" && typeof m.opponent !== "string" && m.opponent.kind === "bot"
727+
? `Bot Lv ${m.opponent.level}`
728+
: "Player 2";
654729
setPlayers({ w: p?.w ?? defaultW, b: p?.b ?? defaultB });
655-
const fresh = m.kind === "portal" ? portalInitialState(m.creator, m.portalMax ?? 2) : initialState();
730+
// Build initial board state
731+
let base: GameState;
732+
if (opts?.customGame) {
733+
base = customBoardState(opts.customGame);
734+
if (m.kind === "portal") {
735+
base.portals = { w: [], b: [], max: m.portalMax ?? 2 };
736+
base.portalCreators = { w: m.creator, b: m.creator };
737+
base.portalAdjacencyRule = false;
738+
}
739+
} else if (opts?.boardLayout) {
740+
base = customBoardState(opts.boardLayout);
741+
// Portal mode with custom layout: add portal fields
742+
if (m.kind === "portal") {
743+
base.portals = { w: [], b: [], max: m.portalMax ?? 2 };
744+
base.portalCreators = { w: m.creator, b: m.creator };
745+
base.portalAdjacencyRule = false;
746+
}
747+
} else if (m.kind === "portal") {
748+
base = portalInitialState(m.creator, m.portalMax ?? 2);
749+
} else {
750+
base = initialState();
751+
}
752+
// For custom mode, X1 pieces are already on the board from the designer
753+
const fresh = m.kind === "custom" && !opts?.customGame
754+
? withCustomPiece(base, m.customPiece!, (m as any).replaces ?? "N")
755+
: base;
656756
dispatch({ type: "new", initial: fresh });
657757
setSelected(null);
658758
setPaused(false);
@@ -733,14 +833,14 @@ export function GameProvider({ children }: { children: ReactNode }) {
733833
}, [store]);
734834

735835
const updateSetting = useCallback(<K extends keyof Settings>(key: K, value: Settings[K]) => {
736-
const updated = cloneStore(store);
836+
const updated = cloneStore(storeRef.current);
737837
updateSettings(updated, { [key]: value } as Partial<Settings>);
738838
setStore(updated);
739839
// Keep ref in sync immediately so any synchronous code that runs right
740840
// after this (e.g. New Game screen's Start button: updateSetting → newGame)
741841
// sees the freshest settings.
742842
storeRef.current = updated;
743-
}, [store]);
843+
}, []);
744844

745845
const recordPuzzleSolved = useCallback((puzzleId: string) => {
746846
if (!activeProfile) return;
@@ -802,6 +902,10 @@ function isHumanControlledColor(mode: Mode, color: Color): boolean {
802902
if (typeof mode.opponent === "string") return true;
803903
return color === "w";
804904
}
905+
if (mode.kind === "custom") {
906+
if (mode.opponent === "two-player") return true;
907+
return color === "w";
908+
}
805909
return true;
806910
}
807911

src/components/Board.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Move, Piece as PieceT, Square, squareName } from "../engine/board";
33
import { allLegalMoves, findKing, inCheck } from "../engine/rules";
44
import { playSound } from "../engine/sound";
55
import { useGame } from "../GameContext";
6+
import { customPieceDefFor } from "../engine/board";
67
import { Piece } from "./Piece";
78

89
function isLegalTarget(legal: Move[], sq: Square): Move | undefined {
@@ -238,6 +239,7 @@ export function Board({ flipped = false }: Props) {
238239
type={lastMove.captured}
239240
set={pieceSet}
240241
rotate={rotateBlackForFixedBoard && capturedColor === "b"}
242+
customPieceDef={undefined}
241243
/>
242244
</span>
243245
)}
@@ -256,6 +258,7 @@ export function Board({ flipped = false }: Props) {
256258
type={piece.type}
257259
set={pieceSet}
258260
rotate={rotateBlackForFixedBoard && piece.color === "b"}
261+
customPieceDef={customPieceDefFor(state, piece)}
259262
/>
260263
</span>
261264
)}

src/components/Piece.tsx

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Color, PieceType } from "../engine/board";
1+
import type { AnimalId, Color, CustomPieceDef, PieceType } from "../engine/board";
22
import type { PieceSet } from "../engine/storage";
33
import { PieceSVG } from "./PieceSVG";
44

@@ -7,15 +7,87 @@ const GLYPH: Record<string, string> = {
77
bK: "\u265A", bQ: "\u265B", bR: "\u265C", bB: "\u265D", bN: "\u265E", bP: "\u265F"
88
};
99

10+
const ANIMAL_EMOJI: Record<string, string> = {
11+
camel: "🐪", cat: "🐱", trex: "🦖", dog: "🐶",
12+
dragon: "🐉", lion: "🦁", eagle: "🦅", wolf: "🐺",
13+
frog: "🐸", unicorn: "🦄"
14+
};
15+
16+
function GDYellowIcon() {
17+
return (
18+
<svg viewBox="0 0 40 40" width="100%" height="100%" aria-hidden="true">
19+
<rect width="40" height="40" fill="#E8A000" rx="3"/>
20+
<rect x="2" y="2" width="22" height="22" fill="#FFCC00" opacity="0.45"/>
21+
<rect x="5" y="10" width="11" height="9" fill="#111"/>
22+
<rect x="6" y="11" width="9" height="7" fill="#00D8E8"/>
23+
<rect x="24" y="10" width="11" height="9" fill="#111"/>
24+
<rect x="25" y="11" width="9" height="7" fill="#00D8E8"/>
25+
<rect x="5" y="24" width="30" height="9" fill="#111"/>
26+
<rect x="6" y="25" width="28" height="7" fill="#00D8E8"/>
27+
</svg>
28+
);
29+
}
30+
31+
function GDRedIcon() {
32+
return (
33+
<svg viewBox="0 0 40 40" width="100%" height="100%" aria-hidden="true">
34+
<rect width="40" height="40" fill="#CC1111" rx="3"/>
35+
<rect x="1" y="1" width="38" height="38" fill="none" stroke="#111" strokeWidth="2"/>
36+
<circle cx="13" cy="14" r="7" fill="#111"/>
37+
<circle cx="13" cy="14" r="5" fill="#FFE800"/>
38+
<circle cx="27" cy="14" r="7" fill="#111"/>
39+
<circle cx="27" cy="14" r="5" fill="#FFE800"/>
40+
<rect x="2" y="23" width="36" height="5" fill="#111"/>
41+
<rect x="3" y="24" width="7" height="3" fill="#FFE800"/>
42+
<rect x="12" y="24" width="6" height="3" fill="#FFE800"/>
43+
<rect x="22" y="24" width="6" height="3" fill="#FFE800"/>
44+
<rect x="30" y="24" width="7" height="3" fill="#FFE800"/>
45+
<rect x="2" y="28" width="11" height="10" fill="#111" rx="1"/>
46+
<rect x="3" y="29" width="9" height="8" fill="#CC1111"/>
47+
<rect x="27" y="28" width="11" height="10" fill="#111" rx="1"/>
48+
<rect x="28" y="29" width="9" height="8" fill="#CC1111"/>
49+
</svg>
50+
);
51+
}
52+
53+
function CreeperIcon() {
54+
return (
55+
<svg viewBox="0 0 40 40" width="100%" height="100%" aria-hidden="true">
56+
<rect width="40" height="40" fill="#55BB44"/>
57+
<rect x="6" y="8" width="9" height="10" fill="#111"/>
58+
<rect x="25" y="8" width="9" height="10" fill="#111"/>
59+
<rect x="15" y="18" width="10" height="7" fill="#111"/>
60+
<rect x="9" y="25" width="6" height="10" fill="#111"/>
61+
<rect x="25" y="25" width="6" height="10" fill="#111"/>
62+
<rect x="15" y="28" width="10" height="7" fill="#111"/>
63+
</svg>
64+
);
65+
}
66+
67+
function CustomPieceIcon({ animal }: { animal?: AnimalId }) {
68+
if (animal === "gd-yellow") return <GDYellowIcon />;
69+
if (animal === "gd-red") return <GDRedIcon />;
70+
if (animal === "creeper") return <CreeperIcon />;
71+
return <span style={{ fontSize: "0.9em", lineHeight: 1 }}>{ANIMAL_EMOJI[animal ?? ""] ?? "?"}</span>;
72+
}
73+
1074
interface Props {
1175
color: Color;
1276
type: PieceType;
1377
set: PieceSet;
1478
rotate?: boolean;
79+
customPieceDef?: CustomPieceDef;
1580
}
1681

17-
export function Piece({ color, type, set, rotate = false }: Props) {
82+
export function Piece({ color, type, set, rotate = false, customPieceDef }: Props) {
1883
const faceClass = rotate ? "piece-face-180" : "";
84+
if (type === "X1") {
85+
return (
86+
<span className={`piece-custom piece-${color} ${faceClass}`}>
87+
<CustomPieceIcon animal={customPieceDef?.animal} />
88+
</span>
89+
);
90+
}
1991
if (set === "classic") {
2092
return (
2193
<span className={`piece-glyph piece-${color} ${faceClass}`}>

src/engine/__tests__/storage.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ function makeStore(): Store {
6262
explodeOnCapture: false,
6363
portalCreatorDefault: "N",
6464
portalOpponentDefault: "bot",
65-
portalMaxDefault: 2
65+
portalMaxDefault: 2,
66+
savedCustomPieces: [],
67+
savedBoardLayouts: [],
68+
savedCustomGames: []
6669
},
6770
savedGames: {}
6871
};

0 commit comments

Comments
 (0)