Add museum map reveal, artifact path puzzle, and homelab media tooling.
Puzzle 1 crossfades to the stitched map with a link to /museum-path; puzzle 2 is a touch-friendly drag-and-order challenge with PDF extract scripts for map and artifact assets.
This commit is contained in:
@@ -14,6 +14,8 @@ dist-ssr
|
|||||||
|
|
||||||
# Local media copies (generated by scripts/setup-dev-media.sh)
|
# Local media copies (generated by scripts/setup-dev-media.sh)
|
||||||
public/media/unlock.mp4
|
public/media/unlock.mp4
|
||||||
|
public/media/artifact-room-map.jpg
|
||||||
|
public/media/artifacts/
|
||||||
public/media/celebration-pop.mp3
|
public/media/celebration-pop.mp3
|
||||||
public/media/celebration-horn.mp3
|
public/media/celebration-horn.mp3
|
||||||
public/media/incorrect.mp3
|
public/media/incorrect.mp3
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
# Agent context — Egyptian Escape Room Digital Lock
|
# Agent context — Egyptian Escape Room Digital Lock
|
||||||
|
|
||||||
Homeschool Egyptian-themed escape room: a browser-based digital lock. Players enter numeric answers from color-coded puzzles; correct code triggers celebration and an unlock video.
|
Homeschool Egyptian-themed escape room: two browser puzzles on separate routes.
|
||||||
|
|
||||||
|
| Route | Puzzle |
|
||||||
|
|-------|--------|
|
||||||
|
| `/` | Artifact room door — 8 color-coded numeric inputs |
|
||||||
|
| `/museum-path` | Museum path — drag 8 of 16 artifacts into order |
|
||||||
|
|
||||||
## Puzzle mapping
|
## Puzzle mapping
|
||||||
|
|
||||||
@@ -21,7 +26,11 @@ Answers are in client-side `src/lockConfig.ts` — acceptable for a kids' escape
|
|||||||
|
|
||||||
## Success flow
|
## Success flow
|
||||||
|
|
||||||
Wrong code → UNLOCK button jiggles + "Incorrect Code!" banner. Correct code → lock animation + confetti (~2s) → fullscreen autoplay of configured MP4 (no reset).
|
**Puzzle 1:** Wrong code → OPEN DOOR jiggles + error banner. Correct code → confetti (~2s) → fullscreen unlock video → map crossfades in (9s before end) with **HELP TED ESCAPE** → `/museum-path`.
|
||||||
|
|
||||||
|
**Puzzle 2:** Drag artifacts from bank into 8 ordered slots; CHECK PATH validates against `src/puzzle2Config.ts`. Correct → confetti + success message. Touch-friendly via `@dnd-kit/core`.
|
||||||
|
|
||||||
|
Map image is generated from PDF pages 9–10 (left/right halves) via `scripts/extract-artifact-room-map.py` (requires `pip install pymupdf`; Pillow for trimming).
|
||||||
|
|
||||||
## Media assets
|
## Media assets
|
||||||
|
|
||||||
@@ -34,10 +43,12 @@ Copied into each environment via setup scripts. Runtime config: `/media/config.j
|
|||||||
| File | Role |
|
| File | Role |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `unlock.mp4` | Success video (`Ted Escape Room 1-5.mp4`) |
|
| `unlock.mp4` | Success video (`Ted Escape Room 1-5.mp4`) |
|
||||||
|
| `artifact-room-map.jpg` | Stitched puzzle 5 map from `EgyptEscapeRoomPrintables.pdf` pages 9–10 |
|
||||||
| `incorrect.mp3` | Wrong-code sound on UNLOCK |
|
| `incorrect.mp3` | Wrong-code sound on UNLOCK |
|
||||||
| `unlock-confirm.mp3` | Success chime with confetti |
|
| `unlock-confirm.mp3` | Success chime with confetti |
|
||||||
| `celebration-pop.mp3` | Balloon pop at confetti start |
|
| `celebration-pop.mp3` | Balloon pop at confetti start |
|
||||||
| `celebration-horn.mp3` | Party horn — plays with pop; first 2s only |
|
| `celebration-horn.mp3` | Party horn — plays with pop; first 2s only |
|
||||||
|
| `artifacts/` | 16 PNGs + `manifest.json` for museum-path puzzle |
|
||||||
|
|
||||||
| Environment | Host path |
|
| Environment | Host path |
|
||||||
|-------------|-----------|
|
|-------------|-----------|
|
||||||
@@ -82,6 +93,10 @@ Deploy order: `setup-prod-media.sh` on Unraid → build/push image → create/up
|
|||||||
## Repo layout
|
## Repo layout
|
||||||
|
|
||||||
- `src/lockConfig.ts` — puzzle colors and answers
|
- `src/lockConfig.ts` — puzzle colors and answers
|
||||||
- `src/DigitalLock.tsx` — main UI
|
- `src/DigitalLock.tsx` — puzzle 1 UI
|
||||||
|
- `src/MuseumPathPuzzle.tsx` — puzzle 2 drag-and-order UI
|
||||||
|
- `src/puzzle2Config.ts` — correct artifact slug order
|
||||||
- `public/media/` — dev media (gitignored copies)
|
- `public/media/` — dev media (gitignored copies)
|
||||||
|
- `scripts/extract-artifact-room-map.py` — PDF pages 9–10 → stitched JPEG
|
||||||
|
- `scripts/extract-artifact-images.py` — 16 embedded map artifacts → `public/media/artifacts/` + `manifest.json`
|
||||||
- `scripts/setup-dev-media.sh`, `scripts/setup-prod-media.sh`, `scripts/build-push.sh`
|
- `scripts/setup-dev-media.sh`, `scripts/setup-prod-media.sh`, `scripts/build-push.sh`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Egyptian Escape Room — Digital Lock
|
# Egyptian Escape Room — Digital Lock
|
||||||
|
|
||||||
A touch-friendly React lock for a homeschool Egyptian escape room. Eight color-coded numeric inputs; correct code plays a celebration and unlock video.
|
A touch-friendly React escape room for homeschool: artifact room door lock (`/`) and museum path drag puzzle (`/museum-path`).
|
||||||
|
|
||||||
## Quick start (local)
|
## Quick start (local)
|
||||||
|
|
||||||
@@ -14,12 +14,21 @@ Open http://localhost:5173. For iPad testing on your LAN: `npm run dev -- --host
|
|||||||
|
|
||||||
## Test checklist
|
## Test checklist
|
||||||
|
|
||||||
- Wrong code → jiggle + "Incorrect Code!"
|
**Puzzle 1** (`/`)
|
||||||
- Correct code (`8`, `9`, `10`, `6`, `8`, `6`, `12`, `4` in order) → confetti → unlock video
|
|
||||||
|
- Wrong code → jiggle + error banner
|
||||||
|
- Correct code (`8`, `9`, `10`, `6`, `8`, `6`, `12`, `4` in order) → confetti → unlock video → map fade-in + **HELP TED ESCAPE**
|
||||||
|
|
||||||
|
**Puzzle 2** (`/museum-path`)
|
||||||
|
|
||||||
|
- Refresh stays on puzzle 2 (does not reset puzzle 1)
|
||||||
|
- Drag 8 artifacts into ordered slots (touch + mouse)
|
||||||
|
- Wrong path + CHECK PATH → error feedback
|
||||||
|
- Correct path → confetti + “Ted escaped the museum!”
|
||||||
|
|
||||||
## Media
|
## Media
|
||||||
|
|
||||||
The unlock clip is copied from `Ted Escape Room 1-5.mp4` in the Egyptian Escape Room documents folder. Dev setup:
|
Media is copied from the Egyptian Escape Room documents folder. `setup-dev-media.sh` also stitches the puzzle 5 map from `EgyptEscapeRoomPrintables.pdf` (pages 9–10). Requires `pip install pymupdf` once. Dev setup:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/setup-dev-media.sh
|
./scripts/setup-dev-media.sh
|
||||||
@@ -27,6 +36,17 @@ The unlock clip is copied from `Ted Escape Room 1-5.mp4` in the Egyptian Escape
|
|||||||
|
|
||||||
Config lives at `public/media/config.json` (see `config.json.example`).
|
Config lives at `public/media/config.json` (see `config.json.example`).
|
||||||
|
|
||||||
|
### Map artifacts (puzzle 5)
|
||||||
|
|
||||||
|
The 16 display images on PDF pages 9–10 are embedded in the printables PDF and can be extracted individually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r scripts/requirements.txt # once
|
||||||
|
python3 scripts/extract-artifact-images.py "/path/to/EgyptEscapeRoomPrintables.pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
Output: `public/media/artifacts/*.png` and `manifest.json` (slug, display name, dimensions). Use `--mode embedded` for raw JPEG/PNG bytes from the PDF instead of page renders.
|
||||||
|
|
||||||
## Docker (local smoke test)
|
## Docker (local smoke test)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Generated
+103
-6
@@ -1,16 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "temp-vite",
|
"name": "egyptian-escape-room",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "temp-vite",
|
"name": "egyptian-escape-room",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6"
|
"react-dom": "^19.2.6",
|
||||||
|
"react-router-dom": "^7.17.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
@@ -268,6 +271,45 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
@@ -1325,6 +1367,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -2386,6 +2441,44 @@
|
|||||||
"react": "^19.2.7"
|
"react": "^19.2.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
|
||||||
|
"integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz",
|
||||||
|
"integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.17.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||||
@@ -2436,6 +2529,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -2503,9 +2602,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"license": "0BSD"
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
|
|||||||
+4
-1
@@ -10,9 +10,12 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6"
|
"react-dom": "^19.2.6",
|
||||||
|
"react-router-dom": "^7.17.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"unlockVideoUrl": "/media/unlock.mp4",
|
"unlockVideoUrl": "/media/unlock.mp4",
|
||||||
|
"mapImageUrl": "/media/artifact-room-map.jpg",
|
||||||
"incorrectUrl": "/media/incorrect.mp3",
|
"incorrectUrl": "/media/incorrect.mp3",
|
||||||
"unlockConfirmUrl": "/media/unlock-confirm.mp3",
|
"unlockConfirmUrl": "/media/unlock-confirm.mp3",
|
||||||
"celebrationPopUrl": "/media/celebration-pop.mp3",
|
"celebrationPopUrl": "/media/celebration-pop.mp3",
|
||||||
|
|||||||
Executable
+150
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Extract the 16 museum-map artifact images from EgyptEscapeRoomPrintables.pdf."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
except ImportError:
|
||||||
|
print("PyMuPDF is required: pip install pymupdf", file=sys.stderr)
|
||||||
|
raise SystemExit(1) from None
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# Identified by xref on pages 9–10 (puzzle 5 map). Slugs match the answer sheet.
|
||||||
|
ARTIFACTS: list[dict[str, int | str]] = [
|
||||||
|
{"page": 9, "xref": 56, "slug": "double-crown", "name": "Double Crown"},
|
||||||
|
{"page": 9, "xref": 57, "slug": "obelisk", "name": "Obelisk"},
|
||||||
|
{"page": 9, "xref": 63, "slug": "goddess-isis", "name": "Goddess Isis"},
|
||||||
|
{"page": 9, "xref": 53, "slug": "canopic-jars", "name": "Canopic Jars"},
|
||||||
|
{"page": 9, "xref": 61, "slug": "howard-carter-excavating-king-tut", "name": "Howard Carter excavating King Tut"},
|
||||||
|
{"page": 9, "xref": 54, "slug": "anubis", "name": "Anubis"},
|
||||||
|
{"page": 9, "xref": 58, "slug": "bust-of-nefertiti", "name": "Bust of Nefertiti"},
|
||||||
|
{"page": 9, "xref": 60, "slug": "god-osiris", "name": "God Osiris"},
|
||||||
|
{"page": 10, "xref": 68, "slug": "pyramid-of-giza", "name": "Pyramid of Giza"},
|
||||||
|
{"page": 10, "xref": 75, "slug": "temple-of-hatshepsut", "name": "Temple of Hatshepsut"},
|
||||||
|
{"page": 10, "xref": 72, "slug": "map-of-the-nile", "name": "Map of the Nile"},
|
||||||
|
{"page": 10, "xref": 67, "slug": "egyptian-painting", "name": "Egyptian painting"},
|
||||||
|
{"page": 10, "xref": 74, "slug": "sarcophagus", "name": "Sarcophagus"},
|
||||||
|
{"page": 10, "xref": 70, "slug": "statue-of-hatshepsut", "name": "Statue of Hatshepsut"},
|
||||||
|
{"page": 10, "xref": 76, "slug": "step-pyramid", "name": "Step Pyramid"},
|
||||||
|
{"page": 10, "xref": 71, "slug": "mastaba", "name": "Mastaba"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def find_image_info(page: fitz.Page, xref: int) -> dict:
|
||||||
|
for info in page.get_image_info(xrefs=True):
|
||||||
|
if info["xref"] == xref:
|
||||||
|
return info
|
||||||
|
raise ValueError(f"Image xref {xref} not found on page {page.number + 1}")
|
||||||
|
|
||||||
|
|
||||||
|
def render_clip(page: fitz.Page, bbox: tuple[float, float, float, float], dpi: float) -> Image.Image:
|
||||||
|
matrix = fitz.Matrix(dpi / 72.0, dpi / 72.0)
|
||||||
|
pixmap = page.get_pixmap(matrix=matrix, clip=fitz.Rect(bbox), alpha=False)
|
||||||
|
return Image.open(io.BytesIO(pixmap.tobytes("png")))
|
||||||
|
|
||||||
|
|
||||||
|
def extract_embedded(doc: fitz.Document, xref: int) -> tuple[Image.Image, str]:
|
||||||
|
data = doc.extract_image(xref)
|
||||||
|
ext = data["ext"]
|
||||||
|
image = Image.open(io.BytesIO(data["image"]))
|
||||||
|
|
||||||
|
smask = data.get("smask")
|
||||||
|
if smask:
|
||||||
|
mask_data = doc.extract_image(smask)
|
||||||
|
mask = Image.open(io.BytesIO(mask_data["image"])).convert("L")
|
||||||
|
if mask.size != image.size:
|
||||||
|
mask = mask.resize(image.size, Image.Resampling.LANCZOS)
|
||||||
|
rgba = image.convert("RGBA")
|
||||||
|
rgba.putalpha(mask)
|
||||||
|
background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
|
||||||
|
image = Image.alpha_composite(background, rgba).convert("RGB")
|
||||||
|
ext = "png"
|
||||||
|
|
||||||
|
return image, ext
|
||||||
|
|
||||||
|
|
||||||
|
def extract_artifacts(
|
||||||
|
pdf_path: str,
|
||||||
|
output_dir: Path,
|
||||||
|
*,
|
||||||
|
dpi: float = 300,
|
||||||
|
mode: str = "render",
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
doc = fitz.open(pdf_path)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
manifest: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for artifact in ARTIFACTS:
|
||||||
|
page_number = int(artifact["page"])
|
||||||
|
xref = int(artifact["xref"])
|
||||||
|
slug = str(artifact["slug"])
|
||||||
|
name = str(artifact["name"])
|
||||||
|
page = doc[page_number - 1]
|
||||||
|
info = find_image_info(page, xref)
|
||||||
|
|
||||||
|
if mode == "render":
|
||||||
|
image = render_clip(page, info["bbox"], dpi)
|
||||||
|
filename = f"{slug}.png"
|
||||||
|
image.save(output_dir / filename, optimize=True)
|
||||||
|
else:
|
||||||
|
image, ext = extract_embedded(doc, xref)
|
||||||
|
filename = f"{slug}.{ext}"
|
||||||
|
if ext == "jpeg":
|
||||||
|
image.save(output_dir / filename, quality=92, optimize=True)
|
||||||
|
else:
|
||||||
|
image.save(output_dir / filename, optimize=True)
|
||||||
|
|
||||||
|
manifest.append(
|
||||||
|
{
|
||||||
|
"slug": slug,
|
||||||
|
"name": name,
|
||||||
|
"page": page_number,
|
||||||
|
"xref": xref,
|
||||||
|
"file": filename,
|
||||||
|
"width": image.width,
|
||||||
|
"height": image.height,
|
||||||
|
"bbox": info["bbox"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(f"Wrote {filename} ({image.width}x{image.height})")
|
||||||
|
finally:
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
manifest_path = output_dir / "manifest.json"
|
||||||
|
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(f"Wrote {manifest_path}")
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("pdf", help="Path to EgyptEscapeRoomPrintables.pdf")
|
||||||
|
parser.add_argument(
|
||||||
|
"output_dir",
|
||||||
|
nargs="?",
|
||||||
|
default="public/media/artifacts",
|
||||||
|
help="Output directory (default: public/media/artifacts)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--dpi", type=float, default=300, help="Render DPI for --mode render (default: 300)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--mode",
|
||||||
|
choices=("render", "embedded"),
|
||||||
|
default="render",
|
||||||
|
help="render: clip from page (default, best for illustrations); embedded: raw image bytes",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
extract_artifacts(args.pdf, Path(args.output_dir), dpi=args.dpi, mode=args.mode)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+107
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Extract puzzle 5 map pages from the printables PDF and stitch into one image."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
except ImportError:
|
||||||
|
print("PyMuPDF is required: pip install pymupdf", file=sys.stderr)
|
||||||
|
raise SystemExit(1) from None
|
||||||
|
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
|
||||||
|
def render_page(doc: fitz.Document, page_number: int, dpi: float) -> Image.Image:
|
||||||
|
page = doc[page_number - 1]
|
||||||
|
zoom = dpi / 72.0
|
||||||
|
matrix = fitz.Matrix(zoom, zoom)
|
||||||
|
pixmap = page.get_pixmap(matrix=matrix, alpha=False)
|
||||||
|
return Image.open(io.BytesIO(pixmap.tobytes("png")))
|
||||||
|
|
||||||
|
|
||||||
|
def trim_white(image: Image.Image, *, pad: int = 8, threshold: int = 245) -> Image.Image:
|
||||||
|
gray = image.convert("L")
|
||||||
|
mask = gray.point(lambda value: 255 if value > threshold else 0)
|
||||||
|
bbox = mask.getbbox()
|
||||||
|
if bbox is None:
|
||||||
|
return image
|
||||||
|
|
||||||
|
left, top, right, bottom = bbox
|
||||||
|
left = max(0, left - pad)
|
||||||
|
top = max(0, top - pad)
|
||||||
|
right = min(image.width, right + pad)
|
||||||
|
bottom = min(image.height, bottom + pad)
|
||||||
|
return image.crop((left, top, right, bottom))
|
||||||
|
|
||||||
|
|
||||||
|
def match_height(left: Image.Image, right: Image.Image) -> tuple[Image.Image, Image.Image]:
|
||||||
|
height = max(left.height, right.height)
|
||||||
|
if left.height != height:
|
||||||
|
canvas = Image.new("RGB", (left.width, height), "white")
|
||||||
|
canvas.paste(left, (0, 0))
|
||||||
|
left = canvas
|
||||||
|
if right.height != height:
|
||||||
|
canvas = Image.new("RGB", (right.width, height), "white")
|
||||||
|
canvas.paste(right, (0, 0))
|
||||||
|
right = canvas
|
||||||
|
return left, right
|
||||||
|
|
||||||
|
|
||||||
|
def stitch_map(
|
||||||
|
pdf_path: str,
|
||||||
|
*,
|
||||||
|
left_page: int = 9,
|
||||||
|
right_page: int = 10,
|
||||||
|
dpi: float = 200,
|
||||||
|
) -> Image.Image:
|
||||||
|
doc = fitz.open(pdf_path)
|
||||||
|
try:
|
||||||
|
if doc.page_count < max(left_page, right_page):
|
||||||
|
raise ValueError(
|
||||||
|
f"PDF has {doc.page_count} pages; need at least page {max(left_page, right_page)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
left = trim_white(render_page(doc, left_page, dpi))
|
||||||
|
right = trim_white(render_page(doc, right_page, dpi))
|
||||||
|
left, right = match_height(left, right)
|
||||||
|
|
||||||
|
combined = Image.new("RGB", (left.width + right.width, left.height), "white")
|
||||||
|
combined.paste(left, (0, 0))
|
||||||
|
combined.paste(right, (left.width, 0))
|
||||||
|
return combined
|
||||||
|
finally:
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("pdf", help="Path to EgyptEscapeRoomPrintables.pdf")
|
||||||
|
parser.add_argument("output", help="Output image path (.jpg or .png)")
|
||||||
|
parser.add_argument("--left-page", type=int, default=9, help="Left half page number (default: 9)")
|
||||||
|
parser.add_argument("--right-page", type=int, default=10, help="Right half page number (default: 10)")
|
||||||
|
parser.add_argument("--dpi", type=float, default=200, help="Render DPI (default: 200)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
image = stitch_map(
|
||||||
|
args.pdf,
|
||||||
|
left_page=args.left_page,
|
||||||
|
right_page=args.right_page,
|
||||||
|
dpi=args.dpi,
|
||||||
|
)
|
||||||
|
|
||||||
|
output = args.output.lower()
|
||||||
|
if output.endswith(".png"):
|
||||||
|
image.save(args.output, optimize=True)
|
||||||
|
else:
|
||||||
|
image.save(args.output, quality=90, optimize=True)
|
||||||
|
|
||||||
|
print(f"Wrote {args.output} ({image.width}x{image.height})")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pymupdf>=1.24
|
||||||
|
Pillow>=10.0
|
||||||
@@ -3,6 +3,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
SOURCE_DIR="${SOURCE_DIR:-/mnt/lockbox/storage/Documents/Home School Projects/Egyptian Escape Room}"
|
SOURCE_DIR="${SOURCE_DIR:-/mnt/lockbox/storage/Documents/Home School Projects/Egyptian Escape Room}"
|
||||||
|
PDF_SOURCE="${PDF_SOURCE:-$SOURCE_DIR/EgyptEscapeRoomPrintables.pdf}"
|
||||||
VIDEO_SOURCE="${VIDEO_SOURCE:-$SOURCE_DIR/Ted Escape Room 1-5.mp4}"
|
VIDEO_SOURCE="${VIDEO_SOURCE:-$SOURCE_DIR/Ted Escape Room 1-5.mp4}"
|
||||||
POP_SOURCE="${POP_SOURCE:-$SOURCE_DIR/celebration-pop.mp3}"
|
POP_SOURCE="${POP_SOURCE:-$SOURCE_DIR/celebration-pop.mp3}"
|
||||||
HORN_SOURCE="${HORN_SOURCE:-$SOURCE_DIR/celebration-horn.mp3}"
|
HORN_SOURCE="${HORN_SOURCE:-$SOURCE_DIR/celebration-horn.mp3}"
|
||||||
@@ -10,7 +11,7 @@ INCORRECT_SOURCE="${INCORRECT_SOURCE:-$SOURCE_DIR/incorrect.mp3}"
|
|||||||
CONFIRM_SOURCE="${CONFIRM_SOURCE:-$SOURCE_DIR/unlock-confirm.mp3}"
|
CONFIRM_SOURCE="${CONFIRM_SOURCE:-$SOURCE_DIR/unlock-confirm.mp3}"
|
||||||
DEST_DIR="$ROOT/public/media"
|
DEST_DIR="$ROOT/public/media"
|
||||||
|
|
||||||
for f in "$VIDEO_SOURCE" "$POP_SOURCE" "$HORN_SOURCE" "$INCORRECT_SOURCE" "$CONFIRM_SOURCE"; do
|
for f in "$PDF_SOURCE" "$VIDEO_SOURCE" "$POP_SOURCE" "$HORN_SOURCE" "$INCORRECT_SOURCE" "$CONFIRM_SOURCE"; do
|
||||||
if [[ ! -f "$f" ]]; then
|
if [[ ! -f "$f" ]]; then
|
||||||
echo "Media file not found: $f" >&2
|
echo "Media file not found: $f" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -23,6 +24,8 @@ cp "$POP_SOURCE" "$DEST_DIR/celebration-pop.mp3"
|
|||||||
cp "$HORN_SOURCE" "$DEST_DIR/celebration-horn.mp3"
|
cp "$HORN_SOURCE" "$DEST_DIR/celebration-horn.mp3"
|
||||||
cp "$INCORRECT_SOURCE" "$DEST_DIR/incorrect.mp3"
|
cp "$INCORRECT_SOURCE" "$DEST_DIR/incorrect.mp3"
|
||||||
cp "$CONFIRM_SOURCE" "$DEST_DIR/unlock-confirm.mp3"
|
cp "$CONFIRM_SOURCE" "$DEST_DIR/unlock-confirm.mp3"
|
||||||
|
python3 "$ROOT/scripts/extract-artifact-room-map.py" "$PDF_SOURCE" "$DEST_DIR/artifact-room-map.jpg"
|
||||||
|
python3 "$ROOT/scripts/extract-artifact-images.py" "$PDF_SOURCE" "$DEST_DIR/artifacts"
|
||||||
cp "$ROOT/public/media/config.json.example" "$DEST_DIR/config.json"
|
cp "$ROOT/public/media/config.json.example" "$DEST_DIR/config.json"
|
||||||
|
|
||||||
echo "Copied media to $DEST_DIR/"
|
echo "Copied media to $DEST_DIR/"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
SOURCE_DIR="${SOURCE_DIR:-/mnt/user/storage/Documents/Home School Projects/Egyptian Escape Room}"
|
SOURCE_DIR="${SOURCE_DIR:-/mnt/user/storage/Documents/Home School Projects/Egyptian Escape Room}"
|
||||||
|
PDF_SOURCE="${PDF_SOURCE:-$SOURCE_DIR/EgyptEscapeRoomPrintables.pdf}"
|
||||||
VIDEO_SOURCE="${VIDEO_SOURCE:-$SOURCE_DIR/Ted Escape Room 1-5.mp4}"
|
VIDEO_SOURCE="${VIDEO_SOURCE:-$SOURCE_DIR/Ted Escape Room 1-5.mp4}"
|
||||||
POP_SOURCE="${POP_SOURCE:-$SOURCE_DIR/celebration-pop.mp3}"
|
POP_SOURCE="${POP_SOURCE:-$SOURCE_DIR/celebration-pop.mp3}"
|
||||||
HORN_SOURCE="${HORN_SOURCE:-$SOURCE_DIR/celebration-horn.mp3}"
|
HORN_SOURCE="${HORN_SOURCE:-$SOURCE_DIR/celebration-horn.mp3}"
|
||||||
@@ -9,7 +11,7 @@ INCORRECT_SOURCE="${INCORRECT_SOURCE:-$SOURCE_DIR/incorrect.mp3}"
|
|||||||
CONFIRM_SOURCE="${CONFIRM_SOURCE:-$SOURCE_DIR/unlock-confirm.mp3}"
|
CONFIRM_SOURCE="${CONFIRM_SOURCE:-$SOURCE_DIR/unlock-confirm.mp3}"
|
||||||
DEST="${DEST:-/mnt/user/appdata/egyptian-escape-room/public}"
|
DEST="${DEST:-/mnt/user/appdata/egyptian-escape-room/public}"
|
||||||
|
|
||||||
for f in "$VIDEO_SOURCE" "$POP_SOURCE" "$HORN_SOURCE" "$INCORRECT_SOURCE" "$CONFIRM_SOURCE"; do
|
for f in "$PDF_SOURCE" "$VIDEO_SOURCE" "$POP_SOURCE" "$HORN_SOURCE" "$INCORRECT_SOURCE" "$CONFIRM_SOURCE"; do
|
||||||
if [[ ! -f "$f" ]]; then
|
if [[ ! -f "$f" ]]; then
|
||||||
echo "Media file not found: $f" >&2
|
echo "Media file not found: $f" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -22,9 +24,12 @@ cp "$POP_SOURCE" "$DEST/celebration-pop.mp3"
|
|||||||
cp "$HORN_SOURCE" "$DEST/celebration-horn.mp3"
|
cp "$HORN_SOURCE" "$DEST/celebration-horn.mp3"
|
||||||
cp "$INCORRECT_SOURCE" "$DEST/incorrect.mp3"
|
cp "$INCORRECT_SOURCE" "$DEST/incorrect.mp3"
|
||||||
cp "$CONFIRM_SOURCE" "$DEST/unlock-confirm.mp3"
|
cp "$CONFIRM_SOURCE" "$DEST/unlock-confirm.mp3"
|
||||||
|
python3 "$ROOT/scripts/extract-artifact-room-map.py" "$PDF_SOURCE" "$DEST/artifact-room-map.jpg"
|
||||||
|
python3 "$ROOT/scripts/extract-artifact-images.py" "$PDF_SOURCE" "$DEST/artifacts"
|
||||||
cat >"$DEST/config.json" <<'EOF'
|
cat >"$DEST/config.json" <<'EOF'
|
||||||
{
|
{
|
||||||
"unlockVideoUrl": "/media/unlock.mp4",
|
"unlockVideoUrl": "/media/unlock.mp4",
|
||||||
|
"mapImageUrl": "/media/artifact-room-map.jpg",
|
||||||
"incorrectUrl": "/media/incorrect.mp3",
|
"incorrectUrl": "/media/incorrect.mp3",
|
||||||
"unlockConfirmUrl": "/media/unlock-confirm.mp3",
|
"unlockConfirmUrl": "/media/unlock-confirm.mp3",
|
||||||
"celebrationPopUrl": "/media/celebration-pop.mp3",
|
"celebrationPopUrl": "/media/celebration-pop.mp3",
|
||||||
|
|||||||
+289
@@ -324,9 +324,298 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.unlock-video {
|
.unlock-video {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.unlock-video--crossfade-out {
|
||||||
|
opacity: 1;
|
||||||
|
animation: video-crossfade-out var(--crossfade-duration, 4.5s) ease-out forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes video-crossfade-out {
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-screen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #0f0d0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-screen--layered {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-screen--crossfade-in {
|
||||||
|
opacity: 0;
|
||||||
|
animation: map-crossfade-in var(--crossfade-duration, 4.5s) ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes map-crossfade-in {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-continue-btn {
|
||||||
|
position: fixed;
|
||||||
|
right: 1.25rem;
|
||||||
|
bottom: 1.25rem;
|
||||||
|
z-index: 110;
|
||||||
|
padding: 0.9rem 1.5rem;
|
||||||
|
font-family: Cinzel, Georgia, serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #1a1510;
|
||||||
|
background: linear-gradient(180deg, #e8c547 0%, #c9a227 48%, #a8841a 100%);
|
||||||
|
border: 2px solid #f4e4bc;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 0 #7a6318,
|
||||||
|
0 8px 20px rgba(0, 0, 0, 0.45),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||||
|
opacity: 0;
|
||||||
|
animation: map-continue-fade-in var(--crossfade-duration, 4.5s) ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-continue-btn:hover {
|
||||||
|
color: #1a1510;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-continue-btn:active {
|
||||||
|
transform: translateY(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes map-continue-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0.5rem);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Museum path puzzle (puzzle 2) */
|
||||||
|
|
||||||
|
.museum-path-scene {
|
||||||
|
position: relative;
|
||||||
|
width: min(100%, 72rem);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.museum-path-panel {
|
||||||
|
width: 100%;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.museum-path__header {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.museum-path__loading {
|
||||||
|
margin: 0;
|
||||||
|
color: #c9b896;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.museum-path__layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.25rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 52rem) {
|
||||||
|
.museum-path__layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.museum-path__section-title {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-family: Cinzel, Georgia, serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: #e8c547;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-bank {
|
||||||
|
min-height: 12rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 2px dashed #4a3f32;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-bank--over {
|
||||||
|
border-color: #e8c547;
|
||||||
|
background: rgba(232, 197, 71, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-bank__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
max-height: 28rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-slots {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-slot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
min-height: 4.5rem;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
background: linear-gradient(180deg, #2a2218 0%, #1f1a14 100%);
|
||||||
|
border: 2px solid #4a3f32;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-slot--over {
|
||||||
|
border-color: #e8c547;
|
||||||
|
box-shadow: 0 0 0 3px rgba(232, 197, 71, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-slot--filled {
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-slot__number {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.75rem;
|
||||||
|
font-family: Cinzel, Georgia, serif;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #c9a227;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-slot__placeholder {
|
||||||
|
flex: 1;
|
||||||
|
color: #7a6f5c;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.4rem;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: grab;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 2px solid #5c4d38;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
transition: border-color 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-card:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-card--dragging {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-card--overlay {
|
||||||
|
width: 6.5rem;
|
||||||
|
opacity: 0.95;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-slot .artifact-card {
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-card__image {
|
||||||
|
width: 3.25rem;
|
||||||
|
height: 3.25rem;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-slot .artifact-card__image {
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artifact-card__name {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: #f4e4bc;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-slot .artifact-card__name {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.museum-path__check {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.museum-path__success {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
font-family: Cinzel, Georgia, serif;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #e8c547;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(92, 68, 28, 0.5);
|
||||||
|
border: 1px solid rgba(201, 162, 39, 0.55);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
animation: error-pop 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|||||||
+10
-1
@@ -1,8 +1,17 @@
|
|||||||
|
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||||
import { DigitalLock } from './DigitalLock';
|
import { DigitalLock } from './DigitalLock';
|
||||||
|
import { MuseumPathPuzzle } from './MuseumPathPuzzle';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return <DigitalLock />;
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<DigitalLock />} />
|
||||||
|
<Route path="/museum-path" element={<MuseumPathPuzzle />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
interface ArtifactRoomMapProps {
|
||||||
|
src: string;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArtifactRoomMap({ src, className = '', style }: ArtifactRoomMapProps) {
|
||||||
|
return (
|
||||||
|
<div className={`map-screen ${className}`.trim()} style={style}>
|
||||||
|
<img
|
||||||
|
className="map-image"
|
||||||
|
src={encodeURI(src)}
|
||||||
|
alt="Museum map — follow the path through the displays to help Ted escape"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+6
-1
@@ -99,7 +99,12 @@ export function DigitalLock() {
|
|||||||
}, [mediaConfig, phase, triggerJiggle, values]);
|
}, [mediaConfig, phase, triggerJiggle, values]);
|
||||||
|
|
||||||
if (phase === 'video') {
|
if (phase === 'video') {
|
||||||
return <UnlockVideo src={mediaConfig.unlockVideoUrl} />;
|
return (
|
||||||
|
<UnlockVideo
|
||||||
|
src={mediaConfig.unlockVideoUrl}
|
||||||
|
mapSrc={mediaConfig.mapImageUrl}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCelebrating = phase === 'celebrating';
|
const isCelebrating = phase === 'celebrating';
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
PointerSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useDraggable,
|
||||||
|
useDroppable,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
type DragStartEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import confetti from 'canvas-confetti';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { CORRECT_PATH, SLOT_COUNT } from './puzzle2Config';
|
||||||
|
import {
|
||||||
|
artifactImageUrl,
|
||||||
|
useArtifactManifest,
|
||||||
|
type ArtifactEntry,
|
||||||
|
} from './useArtifactManifest';
|
||||||
|
|
||||||
|
type DropZoneId = 'bank' | `slot-${number}`;
|
||||||
|
|
||||||
|
function parseDropZone(id: string): { type: 'bank' } | { type: 'slot'; index: number } | null {
|
||||||
|
if (id === 'bank') return { type: 'bank' };
|
||||||
|
if (id.startsWith('slot-')) {
|
||||||
|
const index = Number(id.slice(5));
|
||||||
|
if (Number.isInteger(index) && index >= 0 && index < SLOT_COUNT) {
|
||||||
|
return { type: 'slot', index };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotDropId(index: number): DropZoneId {
|
||||||
|
return `slot-${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathCorrect(slots: (string | null)[]): boolean {
|
||||||
|
if (slots.some((slug) => slug === null)) return false;
|
||||||
|
return slots.every((slug, index) => slug === CORRECT_PATH[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSlug(
|
||||||
|
slots: (string | null)[],
|
||||||
|
activeSlug: string,
|
||||||
|
fromSlot: number,
|
||||||
|
toIndex: number,
|
||||||
|
): (string | null)[] {
|
||||||
|
const next = [...slots];
|
||||||
|
const displaced = next[toIndex];
|
||||||
|
|
||||||
|
if (fromSlot >= 0) {
|
||||||
|
next[fromSlot] = displaced;
|
||||||
|
}
|
||||||
|
|
||||||
|
next[toIndex] = activeSlug;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DraggableArtifactProps {
|
||||||
|
artifact: ArtifactEntry;
|
||||||
|
dragging?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggableArtifact({ artifact, dragging }: DraggableArtifactProps) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||||
|
id: artifact.slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = transform
|
||||||
|
? { transform: CSS.Translate.toString(transform) }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`artifact-card ${isDragging || dragging ? 'artifact-card--dragging' : ''}`}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className="artifact-card__image"
|
||||||
|
src={artifactImageUrl(artifact.file)}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
<span className="artifact-card__name">{artifact.name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArtifactCardOverlay({ artifact }: { artifact: ArtifactEntry }) {
|
||||||
|
return (
|
||||||
|
<div className="artifact-card artifact-card--overlay">
|
||||||
|
<img
|
||||||
|
className="artifact-card__image"
|
||||||
|
src={artifactImageUrl(artifact.file)}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
<span className="artifact-card__name">{artifact.name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArtifactBank({
|
||||||
|
artifacts,
|
||||||
|
bankSlugs,
|
||||||
|
}: {
|
||||||
|
artifacts: ArtifactEntry[];
|
||||||
|
bankSlugs: string[];
|
||||||
|
}) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id: 'bank' });
|
||||||
|
const bankArtifacts = artifacts.filter((a) => bankSlugs.includes(a.slug));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`artifact-bank ${isOver ? 'artifact-bank--over' : ''}`}
|
||||||
|
aria-label="Available artifacts"
|
||||||
|
>
|
||||||
|
<h2 className="museum-path__section-title">Artifacts</h2>
|
||||||
|
<div className="artifact-bank__grid">
|
||||||
|
{bankArtifacts.map((artifact) => (
|
||||||
|
<DraggableArtifact key={artifact.slug} artifact={artifact} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PathSlot({ index, artifact }: { index: number; artifact: ArtifactEntry | null }) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id: slotDropId(index) });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`path-slot ${isOver ? 'path-slot--over' : ''} ${artifact ? 'path-slot--filled' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="path-slot__number" aria-hidden="true">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
{artifact ? (
|
||||||
|
<DraggableArtifact artifact={artifact} />
|
||||||
|
) : (
|
||||||
|
<span className="path-slot__placeholder">Drop artifact here</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MuseumPathPuzzle() {
|
||||||
|
const manifest = useArtifactManifest();
|
||||||
|
const [slots, setSlots] = useState<(string | null)[]>(() => Array(SLOT_COUNT).fill(null));
|
||||||
|
const [activeSlug, setActiveSlug] = useState<string | null>(null);
|
||||||
|
const [phase, setPhase] = useState<'playing' | 'solved'>('playing');
|
||||||
|
const [showError, setShowError] = useState(false);
|
||||||
|
const [jiggle, setJiggle] = useState(false);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
|
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 8 } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const artifactsBySlug = useMemo(() => {
|
||||||
|
if (!manifest) return new Map<string, ArtifactEntry>();
|
||||||
|
return new Map(manifest.map((a) => [a.slug, a]));
|
||||||
|
}, [manifest]);
|
||||||
|
|
||||||
|
const bankSlugs = useMemo(() => {
|
||||||
|
if (!manifest) return [];
|
||||||
|
const inSlots = new Set(slots.filter(Boolean));
|
||||||
|
return manifest.map((a) => a.slug).filter((slug) => !inSlots.has(slug));
|
||||||
|
}, [manifest, slots]);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
|
setActiveSlug(String(event.active.id));
|
||||||
|
setShowError(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||||
|
setActiveSlug(null);
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const activeSlugId = String(active.id);
|
||||||
|
const dropZone = parseDropZone(String(over.id));
|
||||||
|
if (!dropZone) return;
|
||||||
|
|
||||||
|
setSlots((prev) => {
|
||||||
|
const fromSlot = prev.findIndex((s) => s === activeSlugId);
|
||||||
|
|
||||||
|
if (dropZone.type === 'bank') {
|
||||||
|
if (fromSlot < 0) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
next[fromSlot] = null;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return moveSlug(prev, activeSlugId, fromSlot, dropZone.index);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const triggerJiggle = useCallback(() => {
|
||||||
|
setJiggle(true);
|
||||||
|
setShowError(true);
|
||||||
|
window.setTimeout(() => setJiggle(false), 600);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCheckPath = useCallback(() => {
|
||||||
|
if (phase !== 'playing') return;
|
||||||
|
|
||||||
|
if (!isPathCorrect(slots)) {
|
||||||
|
triggerJiggle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowError(false);
|
||||||
|
setPhase('solved');
|
||||||
|
|
||||||
|
const confettiColors = ['#e8c547', '#c9a227', '#f4e4bc', '#d4a84b'];
|
||||||
|
const duration = 2000;
|
||||||
|
const end = Date.now() + duration;
|
||||||
|
|
||||||
|
const frame = () => {
|
||||||
|
confetti({
|
||||||
|
particleCount: 4,
|
||||||
|
angle: 60,
|
||||||
|
spread: 55,
|
||||||
|
origin: { x: 0, y: 0.7 },
|
||||||
|
colors: confettiColors,
|
||||||
|
});
|
||||||
|
confetti({
|
||||||
|
particleCount: 4,
|
||||||
|
angle: 120,
|
||||||
|
spread: 55,
|
||||||
|
origin: { x: 1, y: 0.7 },
|
||||||
|
colors: confettiColors,
|
||||||
|
});
|
||||||
|
if (Date.now() < end) {
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
frame();
|
||||||
|
}, [phase, slots, triggerJiggle]);
|
||||||
|
|
||||||
|
if (!manifest) {
|
||||||
|
return (
|
||||||
|
<div className="museum-path-scene">
|
||||||
|
<div className="sarcophagus-panel museum-path-panel">
|
||||||
|
<p className="museum-path__loading">Loading artifacts…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeArtifact = activeSlug ? artifactsBySlug.get(activeSlug) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="museum-path-scene">
|
||||||
|
<div className="sarcophagus-panel museum-path-panel">
|
||||||
|
<header className="lock-header museum-path__header">
|
||||||
|
<h1>Museum Path</h1>
|
||||||
|
<p className="lock-subtitle">
|
||||||
|
Drag the displays Ted passes into order — then check your path!
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||||
|
<div className="museum-path__layout">
|
||||||
|
<ArtifactBank artifacts={manifest} bankSlugs={bankSlugs} />
|
||||||
|
|
||||||
|
<div className="path-slots" role="list" aria-label="Museum path order">
|
||||||
|
<h2 className="museum-path__section-title">Your path</h2>
|
||||||
|
{slots.map((slug, index) => (
|
||||||
|
<PathSlot
|
||||||
|
key={slotDropId(index)}
|
||||||
|
index={index}
|
||||||
|
artifact={slug ? (artifactsBySlug.get(slug) ?? null) : null}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
{activeArtifact ? <ArtifactCardOverlay artifact={activeArtifact} /> : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
{showError && (
|
||||||
|
<p className="lock-error" role="alert">
|
||||||
|
That path isn't right — check the map and try again!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'solved' ? (
|
||||||
|
<p className="museum-path__success" role="status">
|
||||||
|
You found the path! Ted escaped the museum!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`unlock-btn museum-path__check ${jiggle ? 'unlock-btn--jiggle' : ''}`}
|
||||||
|
onClick={handleCheckPath}
|
||||||
|
>
|
||||||
|
CHECK PATH
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+56
-4
@@ -1,11 +1,38 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ArtifactRoomMap } from './ArtifactRoomMap';
|
||||||
|
|
||||||
|
const MAP_FADE_LEAD_SECONDS = 9;
|
||||||
|
const CROSSFADE_DURATION_SECONDS = 4.5;
|
||||||
|
|
||||||
|
const crossfadeStyle = {
|
||||||
|
'--crossfade-duration': `${CROSSFADE_DURATION_SECONDS}s`,
|
||||||
|
} as CSSProperties;
|
||||||
|
|
||||||
interface UnlockVideoProps {
|
interface UnlockVideoProps {
|
||||||
src: string;
|
src: string;
|
||||||
|
mapSrc: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UnlockVideo({ src }: UnlockVideoProps) {
|
export function UnlockVideo({ src, mapSrc }: UnlockVideoProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const crossfadeStarted = useRef(false);
|
||||||
|
const [crossfadeActive, setCrossfadeActive] = useState(false);
|
||||||
|
const [videoEnded, setVideoEnded] = useState(false);
|
||||||
|
|
||||||
|
const maybeStartCrossfade = useCallback(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || crossfadeStarted.current) return;
|
||||||
|
|
||||||
|
const { duration, currentTime } = video;
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0) return;
|
||||||
|
|
||||||
|
const lead = Math.min(MAP_FADE_LEAD_SECONDS, duration);
|
||||||
|
if (duration - currentTime <= lead) {
|
||||||
|
crossfadeStarted.current = true;
|
||||||
|
setCrossfadeActive(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
@@ -22,15 +49,40 @@ export function UnlockVideo({ src }: UnlockVideoProps) {
|
|||||||
}, [src]);
|
}, [src]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="video-screen">
|
<div
|
||||||
|
className={`video-screen ${crossfadeActive ? 'video-screen--crossfading' : ''}`}
|
||||||
|
style={crossfadeActive ? crossfadeStyle : undefined}
|
||||||
|
>
|
||||||
|
{crossfadeActive && (
|
||||||
|
<ArtifactRoomMap
|
||||||
|
src={mapSrc}
|
||||||
|
className="map-screen--layered map-screen--crossfade-in"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!videoEnded && (
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className="unlock-video"
|
className={`unlock-video ${crossfadeActive ? 'unlock-video--crossfade-out' : ''}`}
|
||||||
src={encodeURI(src)}
|
src={encodeURI(src)}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
playsInline
|
playsInline
|
||||||
|
onLoadedMetadata={maybeStartCrossfade}
|
||||||
|
onTimeUpdate={maybeStartCrossfade}
|
||||||
|
onEnded={() => setVideoEnded(true)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{crossfadeActive && (
|
||||||
|
<Link
|
||||||
|
to="/museum-path"
|
||||||
|
className="map-continue-btn"
|
||||||
|
style={crossfadeStyle}
|
||||||
|
>
|
||||||
|
HELP TED ESCAPE
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export interface MediaConfig {
|
export interface MediaConfig {
|
||||||
unlockVideoUrl: string;
|
unlockVideoUrl: string;
|
||||||
|
mapImageUrl: string;
|
||||||
incorrectUrl: string;
|
incorrectUrl: string;
|
||||||
unlockConfirmUrl: string;
|
unlockConfirmUrl: string;
|
||||||
celebrationPopUrl: string;
|
celebrationPopUrl: string;
|
||||||
@@ -9,6 +10,7 @@ export interface MediaConfig {
|
|||||||
|
|
||||||
export const DEFAULT_MEDIA_CONFIG: MediaConfig = {
|
export const DEFAULT_MEDIA_CONFIG: MediaConfig = {
|
||||||
unlockVideoUrl: '/media/unlock.mp4',
|
unlockVideoUrl: '/media/unlock.mp4',
|
||||||
|
mapImageUrl: '/media/artifact-room-map.jpg',
|
||||||
incorrectUrl: '/media/incorrect.mp3',
|
incorrectUrl: '/media/incorrect.mp3',
|
||||||
unlockConfirmUrl: '/media/unlock-confirm.mp3',
|
unlockConfirmUrl: '/media/unlock-confirm.mp3',
|
||||||
celebrationPopUrl: '/media/celebration-pop.mp3',
|
celebrationPopUrl: '/media/celebration-pop.mp3',
|
||||||
@@ -20,6 +22,7 @@ export function mergeMediaConfig(partial: Partial<MediaConfig> | null): MediaCon
|
|||||||
if (!partial) return DEFAULT_MEDIA_CONFIG;
|
if (!partial) return DEFAULT_MEDIA_CONFIG;
|
||||||
return {
|
return {
|
||||||
unlockVideoUrl: partial.unlockVideoUrl ?? DEFAULT_MEDIA_CONFIG.unlockVideoUrl,
|
unlockVideoUrl: partial.unlockVideoUrl ?? DEFAULT_MEDIA_CONFIG.unlockVideoUrl,
|
||||||
|
mapImageUrl: partial.mapImageUrl ?? DEFAULT_MEDIA_CONFIG.mapImageUrl,
|
||||||
incorrectUrl: partial.incorrectUrl ?? DEFAULT_MEDIA_CONFIG.incorrectUrl,
|
incorrectUrl: partial.incorrectUrl ?? DEFAULT_MEDIA_CONFIG.incorrectUrl,
|
||||||
unlockConfirmUrl: partial.unlockConfirmUrl ?? DEFAULT_MEDIA_CONFIG.unlockConfirmUrl,
|
unlockConfirmUrl: partial.unlockConfirmUrl ?? DEFAULT_MEDIA_CONFIG.unlockConfirmUrl,
|
||||||
celebrationPopUrl: partial.celebrationPopUrl ?? DEFAULT_MEDIA_CONFIG.celebrationPopUrl,
|
celebrationPopUrl: partial.celebrationPopUrl ?? DEFAULT_MEDIA_CONFIG.celebrationPopUrl,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/** Correct museum exit path — artifact slugs in order (puzzle 5). */
|
||||||
|
export const CORRECT_PATH: readonly string[] = [
|
||||||
|
'obelisk',
|
||||||
|
'goddess-isis',
|
||||||
|
'canopic-jars',
|
||||||
|
'pyramid-of-giza',
|
||||||
|
'map-of-the-nile',
|
||||||
|
'howard-carter-excavating-king-tut',
|
||||||
|
'bust-of-nefertiti',
|
||||||
|
'god-osiris',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const SLOT_COUNT = CORRECT_PATH.length;
|
||||||
|
|
||||||
|
export const ARTIFACTS_BASE_URL = '/media/artifacts';
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ARTIFACTS_BASE_URL } from './puzzle2Config';
|
||||||
|
|
||||||
|
export interface ArtifactEntry {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
file: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useArtifactManifest(): ArtifactEntry[] | null {
|
||||||
|
const [manifest, setManifest] = useState<ArtifactEntry[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
fetch(`${ARTIFACTS_BASE_URL}/manifest.json`)
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data: ArtifactEntry[] | null) => {
|
||||||
|
if (cancelled || !data) return;
|
||||||
|
setManifest(data);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* manifest unavailable until setup script runs */
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function artifactImageUrl(file: string): string {
|
||||||
|
return `${ARTIFACTS_BASE_URL}/${encodeURIComponent(file)}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user