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:
2026-06-11 09:57:23 -04:00
parent c810efd8e5
commit 7c68aa3bd3
20 changed files with 1179 additions and 29 deletions
+2
View File
@@ -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
+18 -3
View File
@@ -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 910 (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 910 |
| `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 910 → 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`
+24 -4
View File
@@ -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 910). 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 910 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
+103 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+150
View File
@@ -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 910 (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()
+107
View File
@@ -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()
+2
View File
@@ -0,0 +1,2 @@
pymupdf>=1.24
Pillow>=10.0
+4 -1
View File
@@ -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/"
+6 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+19
View File
@@ -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
View File
@@ -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';
+315
View File
@@ -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&apos;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>
);
}
+63 -11
View File
@@ -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
<video className={`video-screen ${crossfadeActive ? 'video-screen--crossfading' : ''}`}
ref={videoRef} style={crossfadeActive ? crossfadeStyle : undefined}
className="unlock-video" >
src={encodeURI(src)} {crossfadeActive && (
controls <ArtifactRoomMap
autoPlay src={mapSrc}
playsInline className="map-screen--layered map-screen--crossfade-in"
/> />
)}
{!videoEnded && (
<video
ref={videoRef}
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> </div>
); );
} }
+3
View File
@@ -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,
+15
View File
@@ -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';
+38
View File
@@ -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)}`;
}