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 ; + return ( + + + } /> + } /> + + + ); } export default App; diff --git a/src/ArtifactRoomMap.tsx b/src/ArtifactRoomMap.tsx new file mode 100644 index 0000000..83a7a21 --- /dev/null +++ b/src/ArtifactRoomMap.tsx @@ -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 ( +
+ Museum map — follow the path through the displays to help Ted escape +
+ ); +} diff --git a/src/DigitalLock.tsx b/src/DigitalLock.tsx index 3b931c3..565c298 100644 --- a/src/DigitalLock.tsx +++ b/src/DigitalLock.tsx @@ -99,7 +99,12 @@ export function DigitalLock() { }, [mediaConfig, phase, triggerJiggle, values]); if (phase === 'video') { - return ; + return ( + + ); } const isCelebrating = phase === 'celebrating'; diff --git a/src/MuseumPathPuzzle.tsx b/src/MuseumPathPuzzle.tsx new file mode 100644 index 0000000..f9b4325 --- /dev/null +++ b/src/MuseumPathPuzzle.tsx @@ -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 ( +
+ + {artifact.name} +
+ ); +} + +function ArtifactCardOverlay({ artifact }: { artifact: ArtifactEntry }) { + return ( +
+ + {artifact.name} +
+ ); +} + +function ArtifactBank({ + artifacts, + bankSlugs, +}: { + artifacts: ArtifactEntry[]; + bankSlugs: string[]; +}) { + const { setNodeRef, isOver } = useDroppable({ id: 'bank' }); + const bankArtifacts = artifacts.filter((a) => bankSlugs.includes(a.slug)); + + return ( +
+

Artifacts

+
+ {bankArtifacts.map((artifact) => ( + + ))} +
+
+ ); +} + +function PathSlot({ index, artifact }: { index: number; artifact: ArtifactEntry | null }) { + const { setNodeRef, isOver } = useDroppable({ id: slotDropId(index) }); + + return ( +
+ + {artifact ? ( + + ) : ( + Drop artifact here + )} +
+ ); +} + +export function MuseumPathPuzzle() { + const manifest = useArtifactManifest(); + const [slots, setSlots] = useState<(string | null)[]>(() => Array(SLOT_COUNT).fill(null)); + const [activeSlug, setActiveSlug] = useState(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(); + 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 ( +
+
+

Loading artifacts…

+
+
+ ); + } + + const activeArtifact = activeSlug ? artifactsBySlug.get(activeSlug) : undefined; + + return ( +
+
+
+

Museum Path

+

+ Drag the displays Ted passes into order — then check your path! +

+
+ + +
+ + +
+

Your path

+ {slots.map((slug, index) => ( + + ))} +
+
+ + + {activeArtifact ? : null} + +
+ + {showError && ( +

+ That path isn't right — check the map and try again! +

+ )} + + {phase === 'solved' ? ( +

+ You found the path! Ted escaped the museum! +

+ ) : ( + + )} +
+
+ ); +} diff --git a/src/UnlockVideo.tsx b/src/UnlockVideo.tsx index 1c6c458..eca0305 100644 --- a/src/UnlockVideo.tsx +++ b/src/UnlockVideo.tsx @@ -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(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 ( -
-