@onnie81/murdoku-spor 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +7 -0
- package/Dockerfile +40 -0
- package/README.md +125 -0
- package/client/index.html +16 -0
- package/client/package.json +17 -0
- package/client/public/favicon.svg +14 -0
- package/client/public/manifest.webmanifest +12 -0
- package/client/src/App.jsx +62 -0
- package/client/src/api.js +40 -0
- package/client/src/art/logo.jsx +29 -0
- package/client/src/art/objects.jsx +158 -0
- package/client/src/art/portraits.jsx +197 -0
- package/client/src/components/Board.jsx +236 -0
- package/client/src/components/CluePanel.jsx +43 -0
- package/client/src/components/DailyView.jsx +114 -0
- package/client/src/components/FriendsView.jsx +112 -0
- package/client/src/components/HistoryView.jsx +80 -0
- package/client/src/components/HomeView.jsx +101 -0
- package/client/src/components/PlayView.jsx +323 -0
- package/client/src/components/SettingsView.jsx +134 -0
- package/client/src/components/Tray.jsx +48 -0
- package/client/src/difficulties.js +2 -0
- package/client/src/game.js +376 -0
- package/client/src/i18n/en.js +187 -0
- package/client/src/i18n/es.js +187 -0
- package/client/src/i18n/index.js +145 -0
- package/client/src/main.jsx +6 -0
- package/client/src/store.js +77 -0
- package/client/src/styles.css +374 -0
- package/client/vite.config.js +16 -0
- package/docker-compose.yml +21 -0
- package/murdoku.bundle +0 -0
- package/package.json +41 -0
- package/scripts/deploy.sh +49 -0
- package/scripts/npm-server-bootstrap.sh +53 -0
- package/scripts/npm-server-deploy.sh +61 -0
- package/scripts/server-bootstrap.sh +64 -0
- package/scripts/server-deploy.sh +40 -0
- package/server/api.js +354 -0
- package/server/auth.js +85 -0
- package/server/db.js +251 -0
- package/server/index.js +33 -0
- package/server/public/assets/index-COhad2G1.js +40 -0
- package/server/public/assets/index-h4AwMTqN.css +1 -0
- package/server/public/favicon.svg +14 -0
- package/server/public/index.html +17 -0
- package/server/public/manifest.webmanifest +12 -0
- package/server/puzzles.js +109 -0
- package/shared/bits.js +100 -0
- package/shared/clues.js +338 -0
- package/shared/generator.js +299 -0
- package/shared/model.js +127 -0
- package/shared/rng.js +51 -0
- package/shared/solver.js +262 -0
package/.dockerignore
ADDED
package/Dockerfile
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# ---------- build stage ----------
|
|
2
|
+
FROM node:22-bookworm-slim AS build
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
|
|
5
|
+
# toolchain fallback in case a native module has no prebuilt binary
|
|
6
|
+
RUN apt-get update \
|
|
7
|
+
&& apt-get install -y --no-install-recommends python3 make g++ \
|
|
8
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
9
|
+
|
|
10
|
+
COPY package.json ./
|
|
11
|
+
COPY client/package.json ./client/
|
|
12
|
+
RUN npm install --no-audit --no-fund && npm --prefix client install --no-audit --no-fund
|
|
13
|
+
|
|
14
|
+
COPY shared ./shared
|
|
15
|
+
COPY server ./server
|
|
16
|
+
COPY client ./client
|
|
17
|
+
RUN npm --prefix client run build \
|
|
18
|
+
&& npm prune --omit=dev
|
|
19
|
+
|
|
20
|
+
# ---------- runtime stage ----------
|
|
21
|
+
FROM node:22-bookworm-slim
|
|
22
|
+
ENV NODE_ENV=production \
|
|
23
|
+
PORT=8080 \
|
|
24
|
+
DATA_DIR=/data
|
|
25
|
+
WORKDIR /app
|
|
26
|
+
|
|
27
|
+
COPY --from=build /app/package.json ./
|
|
28
|
+
COPY --from=build /app/node_modules ./node_modules
|
|
29
|
+
COPY --from=build /app/shared ./shared
|
|
30
|
+
COPY --from=build /app/server ./server
|
|
31
|
+
|
|
32
|
+
RUN mkdir -p /data && chown -R node:node /data /app
|
|
33
|
+
USER node
|
|
34
|
+
VOLUME /data
|
|
35
|
+
EXPOSE 8080
|
|
36
|
+
|
|
37
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
|
38
|
+
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
|
39
|
+
|
|
40
|
+
CMD ["node", "server/index.js"]
|
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Murdoku
|
|
2
|
+
|
|
3
|
+
A complete online murder-mystery placement-deduction game — the pencil-and-paper
|
|
4
|
+
"Murdoku" genre (Cluedo × Sudoku), fully procedural and multiplayer-social.
|
|
5
|
+
|
|
6
|
+
A victim and a cast of suspects must be placed on an illustrated crime scene:
|
|
7
|
+
**each row and each column holds exactly one person**, furniture blocks squares,
|
|
8
|
+
rooms and testimonies constrain everyone — and whoever ends up **alone with the
|
|
9
|
+
victim** is the killer.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Procedural cases** — every board is generated: room layout, furniture,
|
|
14
|
+
ground truth, and a minimal clue set proven to have a **unique solution** that
|
|
15
|
+
is **solvable without blind guessing** (propagation + single-assumption logic).
|
|
16
|
+
- **5 difficulties** — Beginner 5×5 → Expert 9×9, graded by the number of
|
|
17
|
+
assumption rounds a logical solver needs, not just clue count.
|
|
18
|
+
- **Daily challenges** — same five seeded cases for the whole world (UTC),
|
|
19
|
+
with global and friends leaderboards, streaks, and a case archive.
|
|
20
|
+
- **Full play kit** — tap or drag placement, legal-move highlighting with
|
|
21
|
+
automatic graying of non-allowed squares, X marks, pencil marks (small
|
|
22
|
+
numbers or mini faces), clue spotlighting + strike-through, undo/redo,
|
|
23
|
+
hints, pause, mistake tracking, three assist levels.
|
|
24
|
+
- **Accounts, sessions, friends** — instant guest play (per-device session),
|
|
25
|
+
optional username+password accounts that adopt the guest history, friend
|
|
26
|
+
requests, and daily leaderboards. SQLite storage, one file.
|
|
27
|
+
- **History tracker** — per-user record: solved/played, streaks, best and
|
|
28
|
+
average times per difficulty, full case log; autosave + resume on any device.
|
|
29
|
+
- **Responsive + touch** — phone, tablet, desktop; pointer-based drag & drop,
|
|
30
|
+
long-press to mark X, keyboard shortcuts (1-9, N, X, U, R, E).
|
|
31
|
+
- **Multilingual** — English and Spanish shipped; clues are structured data
|
|
32
|
+
rendered through per-language templates, so adding a language is one file.
|
|
33
|
+
- **Original art** — 12 hand-drawn SVG suspect portraits, 16 furniture icons,
|
|
34
|
+
8 scene themes, logo — no external assets, no fonts to load.
|
|
35
|
+
|
|
36
|
+
## Quick start (Docker)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
docker compose up -d --build
|
|
40
|
+
# open http://localhost:8080
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Data (users, games, puzzles) lives in the `murdoku-data` volume as a single
|
|
44
|
+
SQLite file. Back it up with:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
docker run --rm -v murdoku-data:/data -v "$PWD:/out" debian \
|
|
48
|
+
cp /data/murdoku.db /out/murdoku-backup.db
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### HTTPS / domain
|
|
52
|
+
|
|
53
|
+
Uncomment the `caddy` service in `docker-compose.yml`, set your domain, remove
|
|
54
|
+
the direct port mapping, and `docker compose up -d`. Caddy provisions TLS
|
|
55
|
+
automatically. (Any reverse proxy works; the app sets `trust proxy`.)
|
|
56
|
+
|
|
57
|
+
### Configuration
|
|
58
|
+
|
|
59
|
+
| Env | Default | Meaning |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `PORT` | `8080` | HTTP port |
|
|
62
|
+
| `DATA_DIR` | `/data` (container) / `./data` (bare) | SQLite location |
|
|
63
|
+
|
|
64
|
+
## Development
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npm install && npm --prefix client install
|
|
68
|
+
npm run build # vite build -> server/public
|
|
69
|
+
npm run dev # serve on :8080
|
|
70
|
+
npm test # engine + API integration tests
|
|
71
|
+
node tests/e2e.mjs # Playwright visual/interaction smoke (needs chromium)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Dev loop with hot reload: `npm run dev` in one terminal,
|
|
75
|
+
`npm --prefix client run dev` in another (Vite on :5173 proxies `/api`).
|
|
76
|
+
|
|
77
|
+
## Architecture
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
shared/ puzzle engine (isomorphic, seeded & deterministic)
|
|
81
|
+
rng.js xmur3 + mulberry32 seeded RNG
|
|
82
|
+
model.js cast, themes, difficulty bands, clue-type sets
|
|
83
|
+
clues.js scene context + bitmask tables, predicates, clue-pool gen
|
|
84
|
+
bits.js 96-bit bitset primitives
|
|
85
|
+
solver.js CSP: propagation (singles, arc revision, hidden singles,
|
|
86
|
+
room counts), uniqueness counting, "what-if" logical solver
|
|
87
|
+
generator.js rooms → furniture → ground truth (victim alone with exactly
|
|
88
|
+
one person) → clue selection/minimization → difficulty grading
|
|
89
|
+
server/ Express + better-sqlite3
|
|
90
|
+
db.js schema + queries (users, sessions, puzzles, games, friendships)
|
|
91
|
+
auth.js cookie sessions, bcrypt accounts, guest→account merge
|
|
92
|
+
puzzles.js daily seeding (UTC), free-play pregeneration pool
|
|
93
|
+
api.js REST: auth, settings, daily, games, history, stats,
|
|
94
|
+
friends, leaderboards
|
|
95
|
+
client/ React + Vite single-page app
|
|
96
|
+
game.js per-game store: placement, legality, notes, X, undo/redo,
|
|
97
|
+
autosave, accusation flow
|
|
98
|
+
components/ Board (SVG scene), Tray, CluePanel, Play/Home/Daily/
|
|
99
|
+
History/Friends/Settings views
|
|
100
|
+
i18n/ en/es dictionaries + structured clue renderer
|
|
101
|
+
art/ original SVG portraits, furniture, logo
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The daily seed is `daily:<UTC date>`; generation is fully deterministic, and
|
|
105
|
+
generated dailies are persisted, so every player gets the identical case.
|
|
106
|
+
|
|
107
|
+
**Honesty model:** the solution ships to the client to power instant assists
|
|
108
|
+
(wrong-placement flags, accusation feedback). Finishing is validated
|
|
109
|
+
server-side against the stored solution, but a determined user could read the
|
|
110
|
+
solution from devtools — fine for a friendly game, worth knowing for prizes.
|
|
111
|
+
|
|
112
|
+
## Adding a language
|
|
113
|
+
|
|
114
|
+
Copy `client/src/i18n/en.js` to e.g. `fr.js`, translate the values (UI strings,
|
|
115
|
+
roles, rooms, objects, scenes, clue templates), and register it in
|
|
116
|
+
`client/src/i18n/index.js` (`DICTS`, `LANGS`). Rooms/objects carry `n` (label)
|
|
117
|
+
and `p` (in-sentence with article) forms so grammar stays natural.
|
|
118
|
+
|
|
119
|
+
## Originality
|
|
120
|
+
|
|
121
|
+
Game mechanics follow the published Murdoku puzzle genre by Manuel Garand
|
|
122
|
+
(one person per row/column, map evidence, the murderer alone with the victim).
|
|
123
|
+
All code, characters, scenes, artwork, and text in this project are original.
|
|
124
|
+
"Murdoku" as a public-facing name/brand belongs to its owners — for a public
|
|
125
|
+
commercial deployment, consider your own title.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
|
+
<meta name="theme-color" content="#14121d" />
|
|
7
|
+
<meta name="description" content="Murdoku — murder-mystery placement puzzles. One person per row and column. Find who was alone with the victim." />
|
|
8
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
9
|
+
<link rel="manifest" href="/manifest.webmanifest" />
|
|
10
|
+
<title>Murdoku</title>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<div id="root"></div>
|
|
14
|
+
<script type="module" src="/src/main.jsx"></script>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "murdoku-client",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "vite build"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"react": "^18.3.1",
|
|
11
|
+
"react-dom": "^18.3.1"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@vitejs/plugin-react": "^4.3.1",
|
|
15
|
+
"vite": "^5.4.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
2
|
+
<rect width="100" height="100" rx="20" fill="#14121d"/>
|
|
3
|
+
<rect x="14" y="14" width="56" height="56" rx="8" fill="#2b2440" stroke="#c9a227" stroke-width="4"/>
|
|
4
|
+
<g stroke="#c9a227" stroke-width="3" opacity="0.75">
|
|
5
|
+
<line x1="33" y1="16" x2="33" y2="68"/>
|
|
6
|
+
<line x1="51" y1="16" x2="51" y2="68"/>
|
|
7
|
+
<line x1="16" y1="33" x2="68" y2="33"/>
|
|
8
|
+
<line x1="16" y1="51" x2="68" y2="51"/>
|
|
9
|
+
</g>
|
|
10
|
+
<circle cx="42" cy="42" r="24" fill="#00000066" stroke="#efe6d0" stroke-width="6.5"/>
|
|
11
|
+
<path d="M60,62 L84,88" stroke="#efe6d0" stroke-width="10" stroke-linecap="round"/>
|
|
12
|
+
<path d="M60,62 L84,88" stroke="#8d1d2c" stroke-width="4.5" stroke-linecap="round"/>
|
|
13
|
+
<path d="M34,34 L50,50 M50,34 L34,50" stroke="#e5484d" stroke-width="5.5" stroke-linecap="round"/>
|
|
14
|
+
</svg>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Murdoku",
|
|
3
|
+
"short_name": "Murdoku",
|
|
4
|
+
"description": "Murder-mystery placement puzzles. One person per row and column — find who was alone with the victim.",
|
|
5
|
+
"start_url": "/",
|
|
6
|
+
"display": "standalone",
|
|
7
|
+
"background_color": "#14121d",
|
|
8
|
+
"theme_color": "#14121d",
|
|
9
|
+
"icons": [
|
|
10
|
+
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
|
|
11
|
+
]
|
|
12
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React, { useEffect, useSyncExternalStore } from 'react';
|
|
2
|
+
import { useApp, initApp, navigate } from './store.js';
|
|
3
|
+
import { onLangChange, getLang, t } from './i18n/index.js';
|
|
4
|
+
import { HomeView } from './components/HomeView.jsx';
|
|
5
|
+
import { PlayView } from './components/PlayView.jsx';
|
|
6
|
+
import { DailyView } from './components/DailyView.jsx';
|
|
7
|
+
import { HistoryView } from './components/HistoryView.jsx';
|
|
8
|
+
import { FriendsView } from './components/FriendsView.jsx';
|
|
9
|
+
import { SettingsView } from './components/SettingsView.jsx';
|
|
10
|
+
import { GlassMark } from './art/logo.jsx';
|
|
11
|
+
|
|
12
|
+
function useLang() {
|
|
13
|
+
return useSyncExternalStore(onLangChange, getLang);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const NAV = [
|
|
17
|
+
['#/', 'home', '⌂'],
|
|
18
|
+
['#/daily', 'daily', '☀'],
|
|
19
|
+
['#/history', 'history', '▤'],
|
|
20
|
+
['#/friends', 'friends', '☺'],
|
|
21
|
+
['#/settings', 'settings', '⚙']
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export default function App() {
|
|
25
|
+
const app = useApp();
|
|
26
|
+
const lang = useLang();
|
|
27
|
+
|
|
28
|
+
useEffect(() => { initApp(); }, []);
|
|
29
|
+
|
|
30
|
+
if (!app.ready) return <div className="boot"><GlassMark size={64} /></div>;
|
|
31
|
+
|
|
32
|
+
const { route } = app;
|
|
33
|
+
const inPlay = route.view === 'play';
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className={`app${inPlay ? ' app-play' : ''}`} key={lang}>
|
|
37
|
+
<main className="main">
|
|
38
|
+
{route.view === 'home' && <HomeView />}
|
|
39
|
+
{route.view === 'play' && <PlayView gameId={route.gameId} />}
|
|
40
|
+
{route.view === 'daily' && <DailyView />}
|
|
41
|
+
{route.view === 'history' && <HistoryView />}
|
|
42
|
+
{route.view === 'friends' && <FriendsView />}
|
|
43
|
+
{route.view === 'settings' && <SettingsView />}
|
|
44
|
+
</main>
|
|
45
|
+
|
|
46
|
+
{!inPlay && (
|
|
47
|
+
<nav className="nav">
|
|
48
|
+
{NAV.map(([hash, key, icon]) => (
|
|
49
|
+
<button key={key}
|
|
50
|
+
className={route.view === key || (key === 'home' && route.view === 'home') ? 'on' : ''}
|
|
51
|
+
onClick={() => navigate(hash)}>
|
|
52
|
+
<i aria-hidden>{icon}</i>
|
|
53
|
+
<span>{t(key)}</span>
|
|
54
|
+
</button>
|
|
55
|
+
))}
|
|
56
|
+
</nav>
|
|
57
|
+
)}
|
|
58
|
+
|
|
59
|
+
{app.toast && <div className="toast" role="status">{app.toast}</div>}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
async function req(path, opts = {}) {
|
|
2
|
+
const res = await fetch('/api' + path, {
|
|
3
|
+
method: opts.method || 'GET',
|
|
4
|
+
headers: opts.body != null ? { 'content-type': 'application/json' } : undefined,
|
|
5
|
+
body: opts.body != null ? JSON.stringify(opts.body) : undefined,
|
|
6
|
+
credentials: 'same-origin',
|
|
7
|
+
keepalive: !!opts.keepalive
|
|
8
|
+
});
|
|
9
|
+
let body = null;
|
|
10
|
+
try { body = await res.json(); } catch { /* empty */ }
|
|
11
|
+
if (!res.ok) {
|
|
12
|
+
const err = new Error(body?.error || `http_${res.status}`);
|
|
13
|
+
err.status = res.status;
|
|
14
|
+
err.code = body?.error;
|
|
15
|
+
throw err;
|
|
16
|
+
}
|
|
17
|
+
return body;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const api = {
|
|
21
|
+
me: () => req('/me'),
|
|
22
|
+
register: (username, password) => req('/auth/register', { method: 'POST', body: { username, password } }),
|
|
23
|
+
login: (username, password) => req('/auth/login', { method: 'POST', body: { username, password } }),
|
|
24
|
+
logout: () => req('/auth/logout', { method: 'POST' }),
|
|
25
|
+
saveSettings: (settings) => req('/settings', { method: 'PUT', body: settings }),
|
|
26
|
+
daily: (date) => req('/daily' + (date ? `?date=${date}` : '')),
|
|
27
|
+
newGame: (mode, difficulty, date) => req('/games', { method: 'POST', body: { mode, difficulty, date } }),
|
|
28
|
+
getGame: (id) => req(`/games/${id}`),
|
|
29
|
+
saveGame: (id, payload, keepalive = false) => req(`/games/${id}`, { method: 'PUT', body: payload, keepalive }),
|
|
30
|
+
finishGame: (id, payload) => req(`/games/${id}/finish`, { method: 'POST', body: payload }),
|
|
31
|
+
abandonGame: (id) => req(`/games/${id}/abandon`, { method: 'POST' }),
|
|
32
|
+
active: () => req('/active'),
|
|
33
|
+
history: (limit = 60, offset = 0) => req(`/history?limit=${limit}&offset=${offset}`),
|
|
34
|
+
stats: () => req('/stats'),
|
|
35
|
+
friends: () => req('/friends'),
|
|
36
|
+
friendRequest: (username) => req('/friends/request', { method: 'POST', body: { username } }),
|
|
37
|
+
friendRespond: (userId, accept) => req('/friends/respond', { method: 'POST', body: { userId, accept } }),
|
|
38
|
+
friendRemove: (userId) => req(`/friends/${userId}`, { method: 'DELETE' }),
|
|
39
|
+
leaderboard: (date, difficulty, scope) => req(`/leaderboard?date=${date}&difficulty=${difficulty}&scope=${scope}`)
|
|
40
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export function Wordmark({ size = 1 }) {
|
|
4
|
+
return (
|
|
5
|
+
<div className="wordmark" style={{ fontSize: `${size}em` }}>
|
|
6
|
+
<GlassMark />
|
|
7
|
+
<span className="wordmark-text">MURDOKU</span>
|
|
8
|
+
</div>
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function GlassMark({ size = 44 }) {
|
|
13
|
+
return (
|
|
14
|
+
<svg viewBox="0 0 100 100" width={size} height={size} className="glassmark" role="img" aria-label="Murdoku">
|
|
15
|
+
<rect x="8" y="8" width="60" height="60" rx="10" fill="#2b2440" stroke="#c9a227" strokeWidth="4" />
|
|
16
|
+
<g stroke="#c9a227" strokeWidth="3" opacity="0.75">
|
|
17
|
+
<line x1="28" y1="10" x2="28" y2="66" />
|
|
18
|
+
<line x1="48" y1="10" x2="48" y2="66" />
|
|
19
|
+
<line x1="10" y1="28" x2="66" y2="28" />
|
|
20
|
+
<line x1="10" y1="48" x2="66" y2="48" />
|
|
21
|
+
</g>
|
|
22
|
+
<circle cx="38" cy="38" r="26" fill="#00000055" stroke="#efe6d0" strokeWidth="7" />
|
|
23
|
+
<circle cx="38" cy="38" r="26" fill="none" stroke="#c9a227" strokeWidth="2.5" />
|
|
24
|
+
<path d="M57,60 L82,86" stroke="#efe6d0" strokeWidth="11" strokeLinecap="round" />
|
|
25
|
+
<path d="M57,60 L82,86" stroke="#8d1d2c" strokeWidth="5" strokeLinecap="round" />
|
|
26
|
+
<path d="M30,30 L46,46 M46,30 L30,46" stroke="#e5484d" strokeWidth="6" strokeLinecap="round" />
|
|
27
|
+
</svg>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Original furniture / object icons, drawn to sit inside a board cell.
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
const S = { fill: 'none', stroke: 'currentColor', strokeWidth: 5, strokeLinecap: 'round', strokeLinejoin: 'round' };
|
|
5
|
+
const F = { fill: 'currentColor', stroke: 'none' };
|
|
6
|
+
|
|
7
|
+
const ICONS = {
|
|
8
|
+
piano: (
|
|
9
|
+
<>
|
|
10
|
+
<path {...F} d="M18,30 L66,30 Q84,34 84,52 Q84,70 62,70 L18,70 Z" opacity="0.85" />
|
|
11
|
+
<rect {...F} x="18" y="30" width="10" height="40" opacity="0.5" />
|
|
12
|
+
<path {...S} strokeWidth="3" d="M28,36 L28,64" opacity="0.6" />
|
|
13
|
+
<path {...F} d="M18,74 L60,74 L60,80 L18,80 Z" opacity="0.5" />
|
|
14
|
+
</>
|
|
15
|
+
),
|
|
16
|
+
armchair: (
|
|
17
|
+
<>
|
|
18
|
+
<path {...F} d="M26,34 Q50,24 74,34 L74,58 L26,58 Z" opacity="0.75" />
|
|
19
|
+
<rect {...F} x="16" y="46" width="14" height="26" rx="6" opacity="0.9" />
|
|
20
|
+
<rect {...F} x="70" y="46" width="14" height="26" rx="6" opacity="0.9" />
|
|
21
|
+
<rect {...F} x="26" y="58" width="48" height="14" rx="4" opacity="0.6" />
|
|
22
|
+
</>
|
|
23
|
+
),
|
|
24
|
+
bookshelf: (
|
|
25
|
+
<>
|
|
26
|
+
<rect {...S} x="24" y="18" width="52" height="64" />
|
|
27
|
+
<line {...S} strokeWidth="4" x1="24" y1="40" x2="76" y2="40" />
|
|
28
|
+
<line {...S} strokeWidth="4" x1="24" y1="60" x2="76" y2="60" />
|
|
29
|
+
{[30, 37, 44, 52, 59, 66].map((x, i) => (
|
|
30
|
+
<rect key={i} {...F} x={x} y={i % 2 ? 24 : 26} width="5" height={i % 2 ? 13 : 11} opacity="0.7" />
|
|
31
|
+
))}
|
|
32
|
+
{[32, 41, 50, 59, 66].map((x, i) => (
|
|
33
|
+
<rect key={i} {...F} x={x} y={i % 2 ? 45 : 47} width="5" height={i % 2 ? 12 : 10} opacity="0.55" />
|
|
34
|
+
))}
|
|
35
|
+
</>
|
|
36
|
+
),
|
|
37
|
+
plant: (
|
|
38
|
+
<>
|
|
39
|
+
<path {...F} d="M34,62 L66,62 L60,84 L40,84 Z" opacity="0.8" />
|
|
40
|
+
<path {...S} strokeWidth="4" d="M50,62 L50,42" />
|
|
41
|
+
<path {...F} d="M50,44 Q34,42 30,26 Q48,28 50,44 Z" opacity="0.7" />
|
|
42
|
+
<path {...F} d="M50,44 Q66,42 70,26 Q52,28 50,44 Z" opacity="0.7" />
|
|
43
|
+
<path {...F} d="M50,38 Q50,20 50,16 Q58,26 50,40 Z" opacity="0.7" />
|
|
44
|
+
</>
|
|
45
|
+
),
|
|
46
|
+
clock: (
|
|
47
|
+
<>
|
|
48
|
+
<rect {...S} x="34" y="14" width="32" height="72" rx="4" />
|
|
49
|
+
<circle {...S} strokeWidth="4" cx="50" cy="32" r="11" />
|
|
50
|
+
<path {...S} strokeWidth="3" d="M50,32 L50,25 M50,32 L55,34" />
|
|
51
|
+
<line {...S} strokeWidth="3" x1="50" y1="48" x2="50" y2="70" opacity="0.7" />
|
|
52
|
+
<circle {...F} cx="50" cy="72" r="5" opacity="0.8" />
|
|
53
|
+
</>
|
|
54
|
+
),
|
|
55
|
+
chest: (
|
|
56
|
+
<>
|
|
57
|
+
<path {...F} d="M22,42 Q22,26 50,26 Q78,26 78,42 L78,50 L22,50 Z" opacity="0.75" />
|
|
58
|
+
<rect {...F} x="22" y="50" width="56" height="26" rx="3" opacity="0.9" />
|
|
59
|
+
<rect {...F} x="45" y="44" width="10" height="14" rx="2" opacity="0.5" />
|
|
60
|
+
<line {...S} strokeWidth="4" x1="32" y1="28" x2="32" y2="76" opacity="0.45" />
|
|
61
|
+
<line {...S} strokeWidth="4" x1="68" y1="28" x2="68" y2="76" opacity="0.45" />
|
|
62
|
+
</>
|
|
63
|
+
),
|
|
64
|
+
stove: (
|
|
65
|
+
<>
|
|
66
|
+
<rect {...F} x="24" y="38" width="52" height="42" rx="4" opacity="0.85" />
|
|
67
|
+
<rect {...S} strokeWidth="4" x="32" y="48" width="24" height="22" opacity="0.7" />
|
|
68
|
+
<circle {...F} cx="68" cy="46" r="3.5" opacity="0.6" />
|
|
69
|
+
<circle {...F} cx="68" cy="56" r="3.5" opacity="0.6" />
|
|
70
|
+
<path {...S} strokeWidth="5" d="M62,38 L62,22 Q62,16 68,16" opacity="0.8" />
|
|
71
|
+
</>
|
|
72
|
+
),
|
|
73
|
+
bed: (
|
|
74
|
+
<>
|
|
75
|
+
<path {...S} d="M20,32 L20,76 M80,44 L80,76" />
|
|
76
|
+
<path {...F} d="M20,52 L80,52 L80,66 L20,66 Z" opacity="0.7" />
|
|
77
|
+
<rect {...F} x="24" y="44" width="18" height="9" rx="4" opacity="0.9" />
|
|
78
|
+
<path {...F} d="M20,32 Q26,26 32,32 L32,44 L20,44 Z" opacity="0.5" />
|
|
79
|
+
</>
|
|
80
|
+
),
|
|
81
|
+
desk: (
|
|
82
|
+
<>
|
|
83
|
+
<rect {...F} x="18" y="36" width="64" height="8" rx="2" opacity="0.9" />
|
|
84
|
+
<rect {...S} strokeWidth="4" x="24" y="44" width="22" height="18" opacity="0.8" />
|
|
85
|
+
<circle {...F} cx="35" cy="53" r="2.5" opacity="0.8" />
|
|
86
|
+
<path {...S} d="M76,44 L76,78 M24,62 L24,78" />
|
|
87
|
+
<rect {...F} x="52" y="26" width="20" height="10" rx="1" opacity="0.5" transform="rotate(-6 62 31)" />
|
|
88
|
+
</>
|
|
89
|
+
),
|
|
90
|
+
statue: (
|
|
91
|
+
<>
|
|
92
|
+
<rect {...F} x="32" y="70" width="36" height="12" opacity="0.85" />
|
|
93
|
+
<rect {...F} x="38" y="62" width="24" height="8" opacity="0.6" />
|
|
94
|
+
<path {...F} d="M44,62 Q40,48 46,40 Q42,34 47,28 Q44,20 50,18 Q56,20 53,28 Q58,34 54,40 Q60,48 56,62 Z" opacity="0.8" />
|
|
95
|
+
</>
|
|
96
|
+
),
|
|
97
|
+
telescope: (
|
|
98
|
+
<>
|
|
99
|
+
<path {...F} d="M28,58 L64,26 L72,36 L36,68 Z" opacity="0.85" />
|
|
100
|
+
<path {...F} d="M62,20 L78,34 L74,39 L58,25 Z" opacity="0.6" />
|
|
101
|
+
<path {...S} d="M46,60 L36,84 M46,60 L58,84" />
|
|
102
|
+
<circle {...F} cx="47" cy="59" r="6" opacity="0.9" />
|
|
103
|
+
</>
|
|
104
|
+
),
|
|
105
|
+
lamp: (
|
|
106
|
+
<>
|
|
107
|
+
<path {...F} d="M34,18 L66,18 L58,42 L42,42 Z" opacity="0.8" />
|
|
108
|
+
<line {...S} x1="50" y1="42" x2="50" y2="78" />
|
|
109
|
+
<path {...S} d="M36,84 Q50,76 64,84" />
|
|
110
|
+
</>
|
|
111
|
+
),
|
|
112
|
+
mirror: (
|
|
113
|
+
<>
|
|
114
|
+
<ellipse {...S} cx="50" cy="42" rx="20" ry="26" />
|
|
115
|
+
<ellipse {...F} cx="50" cy="42" rx="14" ry="20" opacity="0.25" />
|
|
116
|
+
<path {...S} strokeWidth="3" d="M42,36 Q46,28 54,28" opacity="0.7" />
|
|
117
|
+
<path {...S} d="M40,70 L36,84 M60,70 L64,84 M38,80 L62,80" />
|
|
118
|
+
</>
|
|
119
|
+
),
|
|
120
|
+
painting: (
|
|
121
|
+
<>
|
|
122
|
+
<rect {...S} x="22" y="24" width="56" height="46" />
|
|
123
|
+
<rect {...S} strokeWidth="2.5" x="29" y="31" width="42" height="32" opacity="0.6" />
|
|
124
|
+
<path {...S} strokeWidth="3" d="M32,56 L44,42 L52,50 L60,38 L68,50" opacity="0.8" />
|
|
125
|
+
<circle {...F} cx="62" cy="38" r="3" opacity="0.6" />
|
|
126
|
+
</>
|
|
127
|
+
),
|
|
128
|
+
crate: (
|
|
129
|
+
<>
|
|
130
|
+
<rect {...S} x="24" y="28" width="52" height="48" />
|
|
131
|
+
<line {...S} strokeWidth="4" x1="24" y1="44" x2="76" y2="44" opacity="0.8" />
|
|
132
|
+
<line {...S} strokeWidth="4" x1="24" y1="60" x2="76" y2="60" opacity="0.8" />
|
|
133
|
+
<rect {...F} x="40" y="32" width="20" height="7" rx="2" opacity="0.6" />
|
|
134
|
+
<circle {...F} cx="31" cy="52" r="2.5" opacity="0.7" />
|
|
135
|
+
<circle {...F} cx="69" cy="52" r="2.5" opacity="0.7" />
|
|
136
|
+
<circle {...F} cx="31" cy="68" r="2.5" opacity="0.7" />
|
|
137
|
+
<circle {...F} cx="69" cy="68" r="2.5" opacity="0.7" />
|
|
138
|
+
</>
|
|
139
|
+
),
|
|
140
|
+
harp: (
|
|
141
|
+
<>
|
|
142
|
+
<path {...S} d="M30,80 L30,30 Q30,14 48,16 Q68,18 70,44 L70,80" />
|
|
143
|
+
<path {...F} d="M26,78 L74,78 L70,86 L30,86 Z" opacity="0.85" />
|
|
144
|
+
{[38, 46, 54, 62].map((x, i) => (
|
|
145
|
+
<line key={i} {...S} strokeWidth="2.5" x1={x} y1={26 + i * 3} x2={x} y2="78" opacity="0.7" />
|
|
146
|
+
))}
|
|
147
|
+
</>
|
|
148
|
+
)
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export function ObjectIcon({ type, size = 40, className = '', title }) {
|
|
152
|
+
return (
|
|
153
|
+
<svg viewBox="0 0 100 100" width={size} height={size} className={`obj-icon ${className}`}
|
|
154
|
+
role="img" aria-label={title || type}>
|
|
155
|
+
{ICONS[type] || <circle {...S} cx="50" cy="50" r="26" />}
|
|
156
|
+
</svg>
|
|
157
|
+
);
|
|
158
|
+
}
|