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)
|
||||
public/media/unlock.mp4
|
||||
public/media/artifact-room-map.jpg
|
||||
public/media/artifacts/
|
||||
public/media/celebration-pop.mp3
|
||||
public/media/celebration-horn.mp3
|
||||
public/media/incorrect.mp3
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# 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
|
||||
|
||||
@@ -21,7 +26,11 @@ Answers are in client-side `src/lockConfig.ts` — acceptable for a kids' escape
|
||||
|
||||
## 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
|
||||
|
||||
@@ -34,10 +43,12 @@ Copied into each environment via setup scripts. Runtime config: `/media/config.j
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `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 |
|
||||
| `unlock-confirm.mp3` | Success chime with confetti |
|
||||
| `celebration-pop.mp3` | Balloon pop at confetti start |
|
||||
| `celebration-horn.mp3` | Party horn — plays with pop; first 2s only |
|
||||
| `artifacts/` | 16 PNGs + `manifest.json` for museum-path puzzle |
|
||||
|
||||
| Environment | Host path |
|
||||
|-------------|-----------|
|
||||
@@ -82,6 +93,10 @@ Deploy order: `setup-prod-media.sh` on Unraid → build/push image → create/up
|
||||
## Repo layout
|
||||
|
||||
- `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)
|
||||
- `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`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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)
|
||||
|
||||
@@ -14,12 +14,21 @@ Open http://localhost:5173. For iPad testing on your LAN: `npm run dev -- --host
|
||||
|
||||
## Test checklist
|
||||
|
||||
- Wrong code → jiggle + "Incorrect Code!"
|
||||
- Correct code (`8`, `9`, `10`, `6`, `8`, `6`, `12`, `4` in order) → confetti → unlock video
|
||||
**Puzzle 1** (`/`)
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
./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`).
|
||||
|
||||
### 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)
|
||||
|
||||
```bash
|
||||
|
||||
Generated
+103
-6
@@ -1,16 +1,19 @@
|
||||
{
|
||||
"name": "temp-vite",
|
||||
"name": "egyptian-escape-room",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "temp-vite",
|
||||
"name": "egyptian-escape-room",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -268,6 +271,45 @@
|
||||
"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": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
@@ -1325,6 +1367,19 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2386,6 +2441,44 @@
|
||||
"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": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||
@@ -2436,6 +2529,12 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -2503,9 +2602,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
|
||||
+4
-1
@@ -10,9 +10,12 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"unlockVideoUrl": "/media/unlock.mp4",
|
||||
"mapImageUrl": "/media/artifact-room-map.jpg",
|
||||
"incorrectUrl": "/media/incorrect.mp3",
|
||||
"unlockConfirmUrl": "/media/unlock-confirm.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)"
|
||||
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}"
|
||||
POP_SOURCE="${POP_SOURCE:-$SOURCE_DIR/celebration-pop.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}"
|
||||
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
|
||||
echo "Media file not found: $f" >&2
|
||||
exit 1
|
||||
@@ -23,6 +24,8 @@ cp "$POP_SOURCE" "$DEST_DIR/celebration-pop.mp3"
|
||||
cp "$HORN_SOURCE" "$DEST_DIR/celebration-horn.mp3"
|
||||
cp "$INCORRECT_SOURCE" "$DEST_DIR/incorrect.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"
|
||||
|
||||
echo "Copied media to $DEST_DIR/"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
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}"
|
||||
POP_SOURCE="${POP_SOURCE:-$SOURCE_DIR/celebration-pop.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}"
|
||||
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
|
||||
echo "Media file not found: $f" >&2
|
||||
exit 1
|
||||
@@ -22,9 +24,12 @@ cp "$POP_SOURCE" "$DEST/celebration-pop.mp3"
|
||||
cp "$HORN_SOURCE" "$DEST/celebration-horn.mp3"
|
||||
cp "$INCORRECT_SOURCE" "$DEST/incorrect.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'
|
||||
{
|
||||
"unlockVideoUrl": "/media/unlock.mp4",
|
||||
"mapImageUrl": "/media/artifact-room-map.jpg",
|
||||
"incorrectUrl": "/media/incorrect.mp3",
|
||||
"unlockConfirmUrl": "/media/unlock-confirm.mp3",
|
||||
"celebrationPopUrl": "/media/celebration-pop.mp3",
|
||||
|
||||
+289
@@ -324,9 +324,298 @@
|
||||
}
|
||||
|
||||
.unlock-video {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
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 { MuseumPathPuzzle } from './MuseumPathPuzzle';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return <DigitalLock />;
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<DigitalLock />} />
|
||||
<Route path="/museum-path" element={<MuseumPathPuzzle />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
if (phase === 'video') {
|
||||
return <UnlockVideo src={mediaConfig.unlockVideoUrl} />;
|
||||
return (
|
||||
<UnlockVideo
|
||||
src={mediaConfig.unlockVideoUrl}
|
||||
mapSrc={mediaConfig.mapImageUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
src: string;
|
||||
mapSrc: string;
|
||||
}
|
||||
|
||||
export function UnlockVideo({ src }: UnlockVideoProps) {
|
||||
export function UnlockVideo({ src, mapSrc }: UnlockVideoProps) {
|
||||
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(() => {
|
||||
const video = videoRef.current;
|
||||
@@ -22,15 +49,40 @@ export function UnlockVideo({ src }: UnlockVideoProps) {
|
||||
}, [src]);
|
||||
|
||||
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
|
||||
ref={videoRef}
|
||||
className="unlock-video"
|
||||
className={`unlock-video ${crossfadeActive ? 'unlock-video--crossfade-out' : ''}`}
|
||||
src={encodeURI(src)}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
onLoadedMetadata={maybeStartCrossfade}
|
||||
onTimeUpdate={maybeStartCrossfade}
|
||||
onEnded={() => setVideoEnded(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{crossfadeActive && (
|
||||
<Link
|
||||
to="/museum-path"
|
||||
className="map-continue-btn"
|
||||
style={crossfadeStyle}
|
||||
>
|
||||
HELP TED ESCAPE
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface MediaConfig {
|
||||
unlockVideoUrl: string;
|
||||
mapImageUrl: string;
|
||||
incorrectUrl: string;
|
||||
unlockConfirmUrl: string;
|
||||
celebrationPopUrl: string;
|
||||
@@ -9,6 +10,7 @@ export interface MediaConfig {
|
||||
|
||||
export const DEFAULT_MEDIA_CONFIG: MediaConfig = {
|
||||
unlockVideoUrl: '/media/unlock.mp4',
|
||||
mapImageUrl: '/media/artifact-room-map.jpg',
|
||||
incorrectUrl: '/media/incorrect.mp3',
|
||||
unlockConfirmUrl: '/media/unlock-confirm.mp3',
|
||||
celebrationPopUrl: '/media/celebration-pop.mp3',
|
||||
@@ -20,6 +22,7 @@ export function mergeMediaConfig(partial: Partial<MediaConfig> | null): MediaCon
|
||||
if (!partial) return DEFAULT_MEDIA_CONFIG;
|
||||
return {
|
||||
unlockVideoUrl: partial.unlockVideoUrl ?? DEFAULT_MEDIA_CONFIG.unlockVideoUrl,
|
||||
mapImageUrl: partial.mapImageUrl ?? DEFAULT_MEDIA_CONFIG.mapImageUrl,
|
||||
incorrectUrl: partial.incorrectUrl ?? DEFAULT_MEDIA_CONFIG.incorrectUrl,
|
||||
unlockConfirmUrl: partial.unlockConfirmUrl ?? DEFAULT_MEDIA_CONFIG.unlockConfirmUrl,
|
||||
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