feat: single player

This commit is contained in:
2025-09-23 18:16:50 +08:00
commit c7d34659db
22 changed files with 4272 additions and 0 deletions

8
.editorconfig Normal file
View File

@@ -0,0 +1,8 @@
root = true
[*]
end_of_line = lf
[*.{js,jsx,ts,tsx,css,html}]
indent_style = space
indent_size = 2

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

28
README.md Normal file
View File

@@ -0,0 +1,28 @@
## Usage
```bash
$ npm install # or pnpm install or yarn install
```
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
## Available Scripts
In the project directory, you can run:
### `npm run dev`
Runs the app in the development mode.<br>
Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
### `npm run build`
Builds the app for production to the `dist` folder.<br>
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## Deployment
Learn more about deploying your application with the [documentations](https://vite.dev/guide/static-deploy.html)

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>路墙棋</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

3351
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "quoridor",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@solidjs/router": "^0.15.3",
"solid-js": "^1.9.9"
},
"devDependencies": {
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "~5.8.3",
"vite": "^7.1.7",
"vite-plugin-singlefile": "^2.3.0",
"vite-plugin-solid": "^2.11.8"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

37
src/App.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { createEffect } from 'solid-js';
import { Route, Router, useNavigate, useSearchParams } from '@solidjs/router'
import Home from './Home'
import Room from './Room'
import Game from './Game';
import { LocalGameStatus, gameStatusFromUrl } from './model';
function App() {
return (
<Router>
<Route path="/" component={() => {
const [params, setParams] = useSearchParams<{ p: string }>();
const game = params.p ? gameStatusFromUrl(params.p) : new LocalGameStatus(0);
createEffect(() => {
if (game.state.moves.length > 0) {
setParams({ p: game.toUrlQuery() }, { replace: true });
} else if (params.p) {
// setParams({ p: "" }) will remove `p`
const newUrl = `${location.pathname}?p=`;
useNavigate()(newUrl, { replace: true, scroll: false });
}
});
createEffect(() => {
if (params.p === "") {
game.clear(0);
}
});
return <>{params.p === undefined ? <Home /> : <Game offline={true} game={game}/>}</>;
}} />
<Route path="/:roomId" component={Room} />
</Router>
)
}
export default App

127
src/Board.tsx Normal file
View File

@@ -0,0 +1,127 @@
import { children, createEffect, createSignal, For, Show, type Accessor, type JSXElement } from "solid-js";
import type { Action, LocalGameStatus } from "./model";
function Board({ player, game, movable }: { player?: 0 | 1, game: LocalGameStatus, movable: Accessor<boolean> }) {
const [action, setAction] = createSignal<Action>("move");
const state = game.state;
const colorText = (player: 0 | 1) => player === 0 ? "红" : "蓝";
createEffect(() => {
if (player && state.wallsLeft[player] <= 0) {
setAction("move")
}
});
return (
<div class="mt-2">
<div class="mt-4 space-x-4 flex items-center justify-center">
<span class="text-xl">
{state.winner === undefined ? `${colorText(state.curPlayer)}方操作` : `${colorText(state.winner)}方胜利`}
</span>
<button onclick={() => game.undo()} type="button" class="inline-block overflow-hidden whitespace-nowrap transition-colors rounded-full box-shadow h-6 leading-6 px-2 text-xs text-black/90 bg-slate-200/80 active:bg-slate-300/70">
</button>
</div>
<div class="max-w-3xl mx-auto">
<svg id="svg" viewBox="-53,-53,106,106" xmlns="http://www.w3.org/2000/svg">
{/* board */}
<path stroke="#272E3B" stroke-width="0.25" fill="none" d="m-45,-45h90v90h-90zm10,0v90m10,0v-90m10,0v90m10,0v-90m10,0v90m10,0v-90m10,0v90m10,0v-90m10,10h-90m0,10h90m0,10h-90m0,10h90m0,10h-90m0,10h90m0,10h-90m0,10h90" />
<For each={[...Array(9).keys()]}>{lineNumber}</For>
<For each={[...Array(9).keys()]}>{columnNumber}</For>
{/* goals */}
<rect x="-45" y="-45" width="90" height="10" fill="#f53f3f" fill-opacity="0.2"></rect>
<rect x="-45" y="35" width="90" height="10" fill="#165dff" fill-opacity="0.2"></rect>
{/* pieces */}
<circle cx={calcX(state.coords[0].x)} cy={calcY(state.coords[0].y)} r="2.3" stroke-width="1.3" stroke="#f53f3f" fill="none"></circle>
<circle cx={calcX(state.coords[1].x)} cy={calcY(state.coords[1].y)} r="2.3" stroke-width="1.3" stroke="#165dff" fill="none"></circle>
{/* walls */}
<For each={game.horizontalWalls()}>
{({ x, y }) => (
<rect x={calcX(x) - 5} y={calcY(y) - 6.5} width="20" height="3" fill="black"></rect>
)}
</For>
<For each={game.verticalWalls()}>
{({ x, y }) => (
<rect x={calcX(x) + 3.5} y={calcY(y) - 15} width="3" height="20" fill="black"></rect>
)}
</For>
{/* available destinations */}
<Show when={movable() && action() === "move"}>
<For each={game.availableDests()}>
{({ x, y }) => (
<g>
<circle cx={calcX(x)} cy={calcY(y)} r="2.3" stroke-width="1.3" stroke={state.curPlayer == 0 ? "#f53f3f" : "#165dff"} stroke-opacity="0.4" fill="none"></circle>
<rect onclick={() => { game.move({ x, y }) }} class="cursor-pointer" x={calcX(x) - 5} y={calcY(y) - 5} width="10" height="10" fill="black" fill-opacity="0"></rect>
</g>
)}
</For>
</Show>
{/* available walls */}
<Show when={movable() && action() === "horizontal"}>
<For each={game.availableHorizontalWalls()}>
{({ x, y }) => (
<rect onclick={() => { game.placeWall({ x, y }, "horizontal") }} class="cursor-pointer" x={calcX(x) + 1} y={calcY(y) - 6.5} width="8" height="3" fill="#6b7280"></rect>
)}
</For>
</Show>
<Show when={movable() && action() === "vertical"}>
<For each={game.availableVerticalWalls()}>
{({ x, y }) => (
<rect onclick={() => { game.placeWall({ x, y }, "vertical") }} class="cursor-pointer" x={calcX(x) + 3.5} y={calcY(y) - 9} width="3" height="8" fill="#6b7280"></rect>
)}
</For>
</Show>
</svg>
</div>
<Show when={movable()}>
<div class="flex flex-wrap items-center justify-center space-x-4">
<Button active={action() === "move"} onclick={() => setAction("move")}></Button>
<Button active={action() === "horizontal"} onclick={() => state.wallsLeft[state.curPlayer] > 0 && setAction("horizontal")}></Button>
<Button active={action() === "vertical"} onclick={() => state.wallsLeft[state.curPlayer] > 0 && setAction("vertical")}></Button>
</div>
</Show>
</div>
)
}
const lineNumber = (line: number) =>
<text
alignment-baseline="central"
dominant-baseline="central"
text-anchor="middle"
font-size="3"
x="-49"
y={calcY(line)}
>
{line + 1}
</text>;
const columnNumber = (column: number) =>
<text
alignment-baseline="central"
dominant-baseline="central"
text-anchor="middle"
font-size="3"
x={calcX(column)}
y="49"
>
{String.fromCharCode(65 + column) /* 65 == 'A' */}
</text>;
const calcX = (index: number) => (index - 8) * 10 + 40;
const calcY = (index: number) => -calcX(index);
function Button(props: { active: boolean, children?: JSXElement, [others: string]: unknown }) {
const resolved = children(() => props.children);
return (
<button {...props} type="button" class={`inline-block overflow-hidden whitespace-nowrap transition-colors rounded-full box-shadow h-10 leading-10 px-4 text-base my-1 ${props.active ? "text-black/40 bg-gray-200 cursor-not-allowed" : "text-black/90 bg-slate-200/80 active:bg-slate-300/70"}`}>
{resolved()}
</button>
)
}
export default Board;

74
src/Game.tsx Normal file
View File

@@ -0,0 +1,74 @@
import { LocalGameStatus } from "./model";
import Board from "./Board";
import { createEffect, createSignal, Show } from "solid-js";
function Game({ offline, player, game, roomId }: { offline?: boolean, player?: 0 | 1, game: LocalGameStatus, roomId?: string }) {
const [movable, setMovable] = createSignal(true);
createEffect(() => {
if (game.state.curPlayer === player || offline && game.state.winner === undefined) {
setMovable(true);
} else {
setMovable(false);
}
});
return (
<>
<div class="select-none flex flex-grow flex-col min-h-screen">
<div class="relative">
<div style="left: 10%; position: absolute; top: 24px;">
<a class="inline-block overflow-hidden whitespace-nowrap transition-colors rounded-full box-shadow h-6 leading-6 px-2 text-xs text-black/90 bg-slate-200/80 active:bg-slate-300/70" href="/">
</a>
</div>
</div>
<div class="pt-2 flex items-center justify-center">
<span class="text-xl"></span>
</div>
<Show when={roomId !== undefined}>
<div class="text-center text-sm mb-4">: {roomId}</div>
</Show>
<div class="flex justify-evenly mt-4">
<div class="w-20 flex flex-col items-center">
<div class="relative box-shadow w-14 h-14 rounded-full border text-center flex items-center justify-center mx-4 my-2">
<div class="text-2xl overflow-hidden">
player1
</div>
<div class="absolute whitespace-nowrap px-1 translate-x-[-50%] rounded-full text-white text-xs bg-orange-600 top-0 left-[7px]">
</div>
<div class="absolute whitespace-nowrap px-1 translate-x-[-50%] rounded-full text-white text-xs bg-orange-600 font-mono top-[39px] left-[46px]">
1
</div>
<div class="absolute whitespace-nowrap px-1 translate-x-[-50%] rounded-full text-white text-xs bg-orange-600 top-[39px] left-[7px]">
</div>
<button type="button" class="block absolute w-14 h-14 rounded-full" />
</div>
<div class="mt-2 w-6 h-6 mx-auto text-center font-bold" style="color: rgb(245, 63, 63);"></div>
<div> {game.state.wallsLeft[0]} </div>
</div>
<div class="w-20 flex flex-col items-center">
<div class="relative box-shadow w-14 h-14 rounded-full border text-center flex items-center justify-center mx-4 my-2">
<div class="text-2xl overflow-hidden">
player2
</div>
<div class="absolute whitespace-nowrap px-1 translate-x-[-50%] rounded-full text-white text-xs bg-gray-600 font-mono top-[39px] left-[46px]">
2
</div>
<div class="absolute left-[3px] top-[42px] border border-black rounded-full w-2.5 h-2.5 bg-gray-500" />
</div>
<div class="mt-2 w-6 h-6 mx-auto text-center font-bold" style="color: rgb(22, 93, 255);"></div>
<div> {game.state.wallsLeft[1]} </div>
</div>
</div>
<Board player={player} game={game} movable={movable} />
</div>
</>
)
}
export default Game;

47
src/Home.tsx Normal file
View File

@@ -0,0 +1,47 @@
function Home() {
return (
<>
<div class="flex-grow flex flex-col">
<h1 class="py-12 flex items-center justify-center">
<span class="text-3xl font-bold"></span>
</h1>
<div class="text-center space-y-4">
<div>
<a class="inline-block overflow-hidden whitespace-nowrap transition-colors rounded-full box-shadow h-10 leading-10 px-4 text-base text-black/90 bg-slate-200/80 active:bg-slate-300/70" href="/?p">
👥
</a>
</div>
<div>
<a class="inline-block overflow-hidden whitespace-nowrap transition-colors rounded-full box-shadow h-10 leading-10 px-4 text-base text-white primary" href="/ntak">
👥
</a>
</div>
<div class="w-56 mx-auto">
<input type="text" placeholder="输入房间号,进指定房间" data-ddg-inputtype="unknown" />
</div>
<div>
<button type="button" class="inline-block overflow-hidden whitespace-nowrap transition-colors rounded-full box-shadow h-10 leading-10 px-4 text-base text-black/40 bg-gray-200 cursor-not-allowed">
👥
</button>
</div>
<div class="max-w-xs mx-auto px-2 space-y-2 text-center">
<div></div>
<div class="flex items-center">
<div class="w-44 text-left truncate font-mono">
🏠 placeholder
</div>
<div class="w-20 truncate">
</div>
<a class="inline-block overflow-hidden whitespace-nowrap transition-colors rounded-full box-shadow h-6 leading-6 px-2 text-xs text-black/90 bg-slate-200/80 active:bg-slate-300/70 ml-auto" href="/ofa1">
</a>
</div>
</div>
</div>
</div>
</>
)
}
export default Home;

61
src/Room.tsx Normal file
View File

@@ -0,0 +1,61 @@
import { useParams } from "@solidjs/router";
function Room() {
const params = useParams();
return (
<>
<div class="flex-grow flex flex-col">
<div class="pt-2 flex items-center justify-center">
<span class="text-xl"></span>
</div>
<div class="text-center text-sm mb-4">: {params.roomId}</div>
<div class="flex flex-wrap justify-center -my-2">
<div class="relative box-shadow w-14 h-14 rounded-full border text-center flex items-center justify-center mx-4 my-2">
<div class="text-2xl overflow-hidden">
player1
</div>
<div class="absolute whitespace-nowrap px-1 translate-x-[-50%] rounded-full text-white text-xs bg-orange-600 top-0 left-[7px]">
</div>
<div class="absolute whitespace-nowrap px-1 translate-x-[-50%] rounded-full text-white text-xs bg-orange-600 font-mono top-[39px] left-[46px]">
1
</div>
<div class="absolute whitespace-nowrap px-1 translate-x-[-50%] rounded-full text-white text-xs bg-orange-600 top-[39px] left-[7px]">
</div>
<button type="button" class="block absolute w-14 h-14 rounded-full" />
</div>
<div class="relative box-shadow w-14 h-14 rounded-full border text-center flex items-center justify-center mx-4 my-2">
<div class="text-2xl overflow-hidden">
player2
</div>
<div class="absolute whitespace-nowrap px-1 translate-x-[-50%] rounded-full text-white text-xs bg-gray-600 font-mono top-[39px] left-[46px]">
2
</div>
<div class="absolute left-[3px] top-[42px] border border-black rounded-full w-2.5 h-2.5 bg-gray-500" />
<button type="button" class="absolute rounded-full block h-6 w-6 leading-6 -top-1 left-9 bg-red-500 text-white active:bg-red-600 box-shadow">
</button>
</div>
</div>
<div class="text-center">
<div class="mt-8 mb-12">
<button type="button" class="inline-block overflow-hidden whitespace-nowrap transition-colors rounded-full box-shadow h-6 leading-6 px-2 text-xs text-black/90 bg-slate-200/80 active:bg-slate-300/70">
</button>
</div>
<div class="my-8">
<button type="button" class="inline-block overflow-hidden whitespace-nowrap transition-colors rounded-full box-shadow h-10 leading-10 px-4 text-base text-white primary">
</button>
</div>
</div>
</div>
</>
)
}
export default Room;

1
src/assets/solid.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

37
src/index.css Normal file
View File

@@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
background: #f0f8ff
}
.primary {
background: #4fb9fc;
color: #fff
}
.primary:active {
background: #3f94ca
}
input[type=text]:focus {
outline-color: #3b82f6
}
input[type=text] {
color: rgba(0,0,0,.9);
border-radius: 6px;
box-shadow: 0 3px 7px -1px rgba(0,0,0,.2),0 2px 5px -2px rgba(0,0,0,.2);
padding: 8px;
width: 100%
}
.box-shadow {
box-shadow:0 3px 7px -1px rgba(0,0,0,.2),0 2px 5px -2px rgba(0,0,0,.2)
}
a.box-shadow:active,
button.box-shadow:active {
box-shadow:0 6px 12px -1px rgba(0,0,0,.3),0 3px 9px -2px rgba(0,0,0,.3)
}

10
src/index.tsx Normal file
View File

@@ -0,0 +1,10 @@
/* @refresh reload */
import { render } from 'solid-js/web'
import './index.css'
import App from './App.tsx'
import Game from './Game.tsx'
import { LocalGameStatus } from './model.tsx'
const root = document.getElementById('root')
render(() => window.location.protocol === 'file:' ? <Game offline={true} game={new LocalGameStatus(0)}/> : <App />, root!)

345
src/model.tsx Normal file
View File

@@ -0,0 +1,345 @@
import { createStore, produce, type SetStoreFunction } from "solid-js/store";
type Wall = "horizontal" | "vertical" | null;
export type Move = {
type: "move" | "horizontal" | "vertical",
from: Coord,
to?: Coord,
}
export type State = {
curPlayer: 0 | 1;
coords: [Coord, Coord];
wallsLeft: [number, number];
walls: Wall[][];
moves: Move[];
winner?: 0 | 1;
}
export class LocalGameStatus {
state: State
setState: SetStoreFunction<State>
constructor(arg: 0 | 1 | State) {
let state, setState;
if (typeof arg === "number") {
[state, setState] = createStore({
curPlayer: arg,
coords: [{ x: 4, y: 0 }, { x: 4, y: 8 }] as [Coord, Coord],
wallsLeft: [MAX_WALLS, MAX_WALLS] as [number, number],
walls: [...Array(8).keys()].map(() => [...Array(8).keys()].map(() => null as Wall)),
moves: [] as Move[]
});
} else {
[state, setState] = createStore(arg);
}
this.state = state;
this.setState = setState;
}
clear(curPlayer: 0 | 1) {
this.setState({
curPlayer,
coords: [{ x: 4, y: 0 }, { x: 4, y: 8 }],
wallsLeft: [MAX_WALLS, MAX_WALLS],
walls: [...Array(8).keys()].map(() => [...Array(8).keys()].map(() => null as Wall)),
moves: []
})
}
toUrlQuery(): string {
return this.state.moves.map(move => {
if (move.type === 'move') {
const from = move.from;
const to = move.to!;
return `m${from.x}${from.y}${to.x}${to.y}`;
} else {
const typeChar = move.type === 'horizontal' ? 'h' : 'v';
return `${typeChar}${move.from.x}${move.from.y}`;
}
}).join('');
}
move(coord: Coord) {
this.setState("moves", this.state.moves.length, { type: "move", from: { ...this.state.coords[this.state.curPlayer] }, to: coord } as Move);
this.setState("coords", this.state.curPlayer, coord);
if (coord.y === (this.state.curPlayer === 0 ? 8 : 0)) {
this.setState("winner", this.state.curPlayer);
}
this.setState("curPlayer", ((this.state.curPlayer + 1) & 1) as typeof this.state.curPlayer);
}
placeWall(coord: { x: number, y: number }, orientation: 'horizontal' | 'vertical'): boolean {
const { x, y } = coord;
const wallsLeft = this.state.wallsLeft[this.state.curPlayer];
if (wallsLeft <= 0) return false;
if (x < 0 || x > 7 || y < 0 || y > 7) return false;
if (this.state.walls[y][x] !== null) return false;
if (orientation === 'horizontal') {
if ((x > 0 && this.state.walls[y][x - 1] === 'horizontal') || (x < 7 && this.state.walls[y][x + 1] === 'horizontal')) {
return false;
}
} else {
if ((y > 0 && this.state.walls[y - 1][x] === 'vertical') || (y < 7 && this.state.walls[y + 1][x] === 'vertical')) {
return false;
}
}
this.setState("walls", y, produce((line) => {
line[x] = orientation;
}));
const player0PathExists = this.hasPathToGoal(this.state.coords[0], 1);
const player1PathExists = this.hasPathToGoal(this.state.coords[1], 2);
if (player0PathExists && player1PathExists) {
this.setState("moves", this.state.moves.length, { type: orientation, from: coord } as Move);
this.setState("wallsLeft", this.state.curPlayer, this.state.wallsLeft[this.state.curPlayer] - 1);
this.setState("curPlayer", ((this.state.curPlayer + 1) & 1) as typeof this.state.curPlayer);
return true;
} else {
this.setState("walls", y, produce((line) => {
line[x] = null;
}))
return false;
}
}
undo() {
let move: Move | undefined;
if (this.state.moves.length === 0) {
return
}
this.setState("moves", produce((moves) => move = moves.pop()));
const coord = move!.from;
switch (move!.type) {
case "move":
this.setState("coords", (this.state.curPlayer + 1) & 1, coord);
this.setState("winner", undefined);
break;
case "horizontal":
case "vertical":
this.setState("wallsLeft", (this.state.curPlayer + 1) & 1, this.state.wallsLeft[(this.state.curPlayer + 1) & 1] + 1);
this.setState("walls", produce((walls) => walls[coord.y][coord.x] = null));
}
this.setState("curPlayer", ((this.state.curPlayer + 1) & 1) as typeof this.state.curPlayer);
}
availableDests(): Coord[] {
const moves: Coord[] = [];
const thisCoord = this.state.coords[this.state.curPlayer];
const otherCoord = this.state.coords[(this.state.curPlayer + 1) & 1];
const { x, y } = thisCoord;
const directions = [
{ dx: 0, dy: 1 }, { dx: 0, dy: -1 },
{ dx: 1, dy: 0 }, { dx: -1, dy: 0 },
];
for (const { dx, dy } of directions) {
const nextX = x + dx;
const nextY = y + dy;
const nextCoord = { x: nextX, y: nextY } as Coord;
if (nextX < 0 || nextX > 8 || nextY < 0 || nextY > 8) {
continue;
}
if (this.isWallBlocking(thisCoord, nextCoord)) {
continue;
}
const isOpponent = nextX === otherCoord.x && nextY === otherCoord.y;
if (!isOpponent) {
moves.push(nextCoord);
} else {
const jumpDirections = [
{ jdx: dx, jdy: dy },
{ jdx: -dy, jdy: dx },
{ jdx: dy, jdy: -dx },
];
for (const { jdx, jdy } of jumpDirections) {
const finalX = otherCoord.x + jdx;
const finalY = otherCoord.y + jdy;
if (finalX < 0 || finalX > 8 || finalY < 0 || finalY > 8) {
continue;
}
const finalCoord = { x: finalX, y: finalY } as Coord;
if (!this.isWallBlocking(otherCoord, finalCoord)) {
moves.push(finalCoord);
}
}
}
}
const uniqueMoves = [...new Map(moves.map(item => [`${item.x},${item.y}`, item])).values()];
return uniqueMoves.filter(m => m.x >= 0 && m.x <= 8 && m.y >= 0 && m.y <= 8);
}
private isWallBlocking(from: Coord, to: Coord): boolean {
const { x: fromX, y: fromY } = from;
const { x: toX, y: toY } = to;
if (fromX === toX) {
const wallY = Math.min(fromY, toY);
if (wallY < 0 || wallY > 7) {
return true
}
if (fromX > 0 && this.state.walls[wallY][fromX - 1] === 'horizontal') return true;
if (fromX < 8 && this.state.walls[wallY][fromX] === 'horizontal') return true;
} else {
const wallX = Math.min(fromX, toX);
if (wallX < 0 || wallX > 7) {
return true
}
if (fromY > 0 && this.state.walls[fromY - 1][wallX] === 'vertical') return true;
if (fromY < 8 && this.state.walls[fromY][wallX] === 'vertical') return true;
}
return false;
}
private hasPathToGoal(startCoord: Coord, player: 1 | 2): boolean {
const goalY = player === 1 ? 8 : 0;
const queue: Coord[] = [startCoord];
const visited = new Set<string>([`${startCoord.x},${startCoord.y}`]);
while (queue.length > 0) {
const current = queue.shift()!;
if (current.y === goalY) {
return true;
}
const { x, y } = current;
const directions = [
{ dx: 0, dy: 1 }, { dx: 0, dy: -1 },
{ dx: 1, dy: 0 }, { dx: -1, dy: 0 },
];
for (const { dx, dy } of directions) {
const nextX = x + dx;
const nextY = y + dy;
if (nextX < 0 || nextX > 8 || nextY < 0 || nextY > 8) continue;
const nextCoord = { x: nextX, y: nextY } as Coord;
const nextKey = `${nextX},${nextY}`;
if (visited.has(nextKey) || this.isWallBlocking(current, nextCoord)) {
continue;
}
visited.add(nextKey);
queue.push(nextCoord);
}
}
return false;
}
availableHorizontalWalls() {
function* gen(status: LocalGameStatus) {
for (let i = 0; i < 8; i++) {
for (let j = 0; j < 8; j++) {
if (
status.state.walls[i][j] === null
&& (j + 1 >= 8 || status.state.walls[i][j + 1] !== "horizontal")
&& (j - 1 < 0 || status.state.walls[i][j - 1] !== "horizontal")
) {
yield { x: j, y: i } as Coord
}
}
}
}
return [...gen(this)];
}
availableVerticalWalls() {
function* gen(status: LocalGameStatus) {
for (let i = 0; i < 8; i++) {
for (let j = 0; j < 8; j++) {
if (
status.state.walls[i][j] === null
&& (i + 1 >= 8 || status.state.walls[i + 1][j] !== "vertical")
&& (i - 1 < 0 || status.state.walls[i - 1][j] !== "vertical")
) {
yield { x: j, y: i } as Coord
}
}
}
}
return [...gen(this)];
}
horizontalWalls() {
function* gen(status: LocalGameStatus) {
for (let i = 0; i < 8; i++) {
for (let j = 0; j < 8; j++) {
if (status.state.walls[i][j] === "horizontal") {
yield { x: j, y: i } as Coord
}
}
}
}
return [...gen(this)];
}
verticalWalls() {
function* gen(status: LocalGameStatus) {
for (let i = 0; i < 8; i++) {
for (let j = 0; j < 8; j++) {
if (status.state.walls[i][j] === "vertical") {
yield { x: j, y: i } as Coord
}
}
}
}
return [...gen(this)];
}
}
type Enumerate<N extends number, Acc extends number[] = []> = Acc['length'] extends N
? Acc[number]
: Enumerate<N, [...Acc, Acc['length']]>
type Range<F extends number, T extends number> = Exclude<Enumerate<T>, Enumerate<F>>
export type Coord = {
x: Range<0, 9>,
y: Range<0, 9>,
}
export const MAX_WALLS = 10
export type Action = "move" | "horizontal" | "vertical"
export function gameStatusFromUrl(movesStr: string): LocalGameStatus {
const game = new LocalGameStatus(0);
let i = 0;
while (i < movesStr.length) {
const type = movesStr[i];
if (type === 'm') {
const toX = parseInt(movesStr[i + 3], 10);
const toY = parseInt(movesStr[i + 4], 10);
if (isNaN(toX) || isNaN(toY)) break;
game.move({ x: toX, y: toY } as Coord);
i += 5;
} else if (type === 'h' || type === 'v') {
const x = parseInt(movesStr[i + 1], 10);
const y = parseInt(movesStr[i + 2], 10);
if (isNaN(x) || isNaN(y)) break;
const orientation = type === 'h' ? 'horizontal' : 'vertical';
game.placeWall({ x, y }, orientation);
i += 3;
} else {
break;
}
}
return game;
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

12
tailwind.config.js Normal file
View File

@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

28
tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
tsconfig.node.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import solid from 'vite-plugin-solid'
import { viteSingleFile } from "vite-plugin-singlefile"
export default defineConfig({
plugins: [solid(), viteSingleFile()],
})