diff --git a/.gitignore b/.gitignore
index 998c385..b960fea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index 427fff2..9364ffd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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`
diff --git a/README.md b/README.md
index 8570c02..0173316 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/package-lock.json b/package-lock.json
index 2b3f5d0..2930b2d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 7bb7571..37451a6 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/public/media/config.json.example b/public/media/config.json.example
index 082e645..3b18ed7 100644
--- a/public/media/config.json.example
+++ b/public/media/config.json.example
@@ -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",
diff --git a/scripts/extract-artifact-images.py b/scripts/extract-artifact-images.py
new file mode 100755
index 0000000..f0d3893
--- /dev/null
+++ b/scripts/extract-artifact-images.py
@@ -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()
diff --git a/scripts/extract-artifact-room-map.py b/scripts/extract-artifact-room-map.py
new file mode 100755
index 0000000..1b8a738
--- /dev/null
+++ b/scripts/extract-artifact-room-map.py
@@ -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()
diff --git a/scripts/requirements.txt b/scripts/requirements.txt
new file mode 100644
index 0000000..839b732
--- /dev/null
+++ b/scripts/requirements.txt
@@ -0,0 +1,2 @@
+pymupdf>=1.24
+Pillow>=10.0
diff --git a/scripts/setup-dev-media.sh b/scripts/setup-dev-media.sh
index a529c88..81382f4 100755
--- a/scripts/setup-dev-media.sh
+++ b/scripts/setup-dev-media.sh
@@ -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/"
diff --git a/scripts/setup-prod-media.sh b/scripts/setup-prod-media.sh
index 9dd7d4e..903694d 100755
--- a/scripts/setup-prod-media.sh
+++ b/scripts/setup-prod-media.sh
@@ -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",
diff --git a/src/App.css b/src/App.css
index 0cda635..cf46c7a 100644
--- a/src/App.css
+++ b/src/App.css
@@ -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;
+}
diff --git a/src/App.tsx b/src/App.tsx
index 9885adc..26a18f3 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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
Loading artifacts…
++ Drag the displays Ted passes into order — then check your path! +
++ That path isn't right — check the map and try again! +
+ )} + + {phase === 'solved' ? ( ++ You found the path! Ted escaped the museum! +
+ ) : ( + + )} +