diff --git a/.gitignore b/.gitignore index 2309cc8..258d1d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,138 +1,6 @@ -# ---> Node -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# vitepress build output -**/.vitepress/dist - -# vitepress cache directory -**/.vitepress/cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* - +*.log +.run.pid +.deploy-installed +data/backups/ +_deploy/ diff --git a/LICENSE b/LICENSE deleted file mode 100644 index c2ffe20..0000000 --- a/LICENSE +++ /dev/null @@ -1,18 +0,0 @@ -MIT License - -Copyright (c) 2026 jessikitty - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and -associated documentation files (the "Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the -following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial -portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT -LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 7fd70ea..3d6a2ee 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,32 @@ a procedural blue "Sad" wisp. --- +## Scene workshop (admin GUI): `admin.html` + +A 3D authoring tool over `data/scene.json` — no more hand-editing JSON. Orbit the build, click to +select, drag on the table to position. Four tabs: + +- **World** — table dimensions (mm). X = length, Z = depth, Y = max ghost height. +- **Anchors** — the AR crests: marker type/id, size, position, yaw. (Note: existing scene used + `apriltag`; the detector is ArUco — the editor lets you set `aruco` and the matching id.) +- **Ghosts** — spawns with `hover` (position + radius + bob + yaw) or `patrol` (editable path of + points, loop period, face-travel) motion, plus lure colour. Drag the ghost to move it; drag + individual patrol points to reshape the path. +- **Buildings** — simple stackable box blockers for occlusion. Drag to place, set W×H×D, and + "Duplicate (stack +Y)" to pile boxes into a structure. (Occlusion rendering comes next; this + authors the geometry.) + +Tools: select (V), add ghost (G), add building (B), add anchor (A). Delete/Esc work on selection. + +**Save** POSTs to the server, which **backs up the old `scene.json`** to `data/backups/` first, +validates, writes, and **hot-reloads** — so connected viewers/phones get the new anchors and +ghosts immediately. **Export** downloads the JSON; **Reload** discards changes and re-fetches. + +Server endpoints: `GET /api/scene` (full scene), `POST /api/scene` (validated save + backup). + +> The scene schema gained a `blockers` array: `{ id, label, position:{x,y,z}, size:{x,y,z} }`, +> where `position` is the footprint corner (origin-relative) and `size` is the box extent in mm. + ## Forgiving scanning (`js/detect-tuned.js`) Raw js-aruco can feel finicky — slow to acquire, quick to drop on blur, jittery once locked. @@ -142,12 +168,17 @@ key numbers — they'll tell us how much multi-marker fusion is worth before we ```bash npm install npm start # PORT defaults to 34034 -# open http://localhost:34034/ar-test.html (camera needs HTTPS on phones) -# open http://localhost:34034/ god-view viewer (viewer.js) +# open http://localhost:34034/admin.html scene workshop (authoring GUI) +# open http://localhost:34034/ar-head.html 3D ghost AR (camera needs HTTPS on phones) +# open http://localhost:34034/ god-view viewer (viewer.js) ``` Env: `PORT` (default 34034), `TICK_HZ` (default 20). +### Deploy box (blackpearl) +`./run.sh {start|stop|restart|status|logs|update}` — background runner for testing. +`./run.sh update` syncs to origin/dev and restarts. `./deploy.sh` is the pull-only one-shot. + --- ## Status / roadmap @@ -159,8 +190,10 @@ Env: `PORT` (default 34034), `TICK_HZ` (default 20). - [x] AR PoC: camera + detection + ghost locked to marker (flat sprite) - [x] 3D LEGO-head ghost holding world orientation (`ar-head.html`) - [x] Pose jitter meter (`jitter.html`) with session logging + JSON export +- [x] Forgiving detection: tuned params, multi-scale, coast + smooth (`detect-tuned.js`) +- [x] Scene workshop admin GUI (`admin.html`) — author anchors, ghosts, motion, buildings; save+backup+hot-reload - [ ] Multi-marker pose fusion (one combined camera pose from all visible crests) -- [ ] Occluder geometry: ghosts vanish behind premapped buildings (depth-only boxes) +- [ ] Occluder geometry: ghosts vanish behind the building blockers (depth-only boxes) - [ ] Phone AR client: render anchored ghosts through the camera using marker pose - [ ] Calibration page: scan each crest, record its measured world position - [ ] Per-ghost colour/sprite wiring from the enriched roster diff --git a/data/ghosts.json b/data/ghosts.json new file mode 100644 index 0000000..14a3964 --- /dev/null +++ b/data/ghosts.json @@ -0,0 +1,2666 @@ +[ + { + "id": "air-marshall-brooks", + "name": "Air Marshall Brooks", + "color": "Red", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 4, + "rangePips": 1, + "chargePips": 3, + "healthMax": 263, + "damageMax": 161, + "abilityId": "sentry", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SentryIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "barney-mcphly", + "name": "Barney McPhly", + "color": "Red", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 4, + "rangePips": 1, + "chargePips": 3, + "healthMax": 263, + "damageMax": 161, + "abilityId": "sentry", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SentryIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "hilary-delivery", + "name": "Hilary Delivery", + "color": "Red", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 4, + "rangePips": 1, + "chargePips": 2, + "healthMax": 300, + "damageMax": 192, + "abilityId": "speed-boost", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "lumberjack-leroy", + "name": "Lumberjack Leroy", + "color": "Red", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 4, + "rangePips": 1, + "chargePips": 3, + "healthMax": 263, + "damageMax": 161, + "abilityId": "jump", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "JumpIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "nagging-nathan", + "name": "Nagging Nathan", + "color": "Red", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 300, + "damageMax": 192, + "abilityId": "speed-boost", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "poor-thaddeus", + "name": "Poor Thaddeus", + "color": "Red", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 2, + "rangePips": 1, + "chargePips": 3, + "healthMax": 338, + "damageMax": 212, + "abilityId": "flight", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "FlightIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "vito-andale", + "name": "Vito Andale", + "color": "Red", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 4, + "rangePips": 1, + "chargePips": 3, + "healthMax": 263, + "damageMax": 161, + "abilityId": "ghost-gang", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SmokeDecoyIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "ben-batter-jackson", + "name": "Ben \"Batter\" Jackson", + "color": "Red", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 5, + "chargePips": 1, + "healthMax": 263, + "damageMax": 212, + "abilityId": "tracking-gloom", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HomingShotIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "doctor-julius", + "name": "Doctor Julius", + "color": "Red", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 4, + "chargePips": 2, + "healthMax": null, + "damageMax": null, + "abilityId": "healing-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealPadIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "janick", + "name": "Janick", + "color": "Red", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": null, + "damageMax": null, + "abilityId": "gloombrella", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomCeilingIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "jenny", + "name": "Jenny", + "color": "Red", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 4, + "chargePips": 2, + "healthMax": 338, + "damageMax": 192, + "abilityId": "healing-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealPadIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "jeremy-jones", + "name": "Jeremy Jones", + "color": "Red", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 300, + "damageMax": 176, + "abilityId": "gloombrella", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomCeilingIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "jimothy", + "name": "Jimothy", + "color": "Red", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 4, + "chargePips": 2, + "healthMax": null, + "damageMax": null, + "abilityId": "ghost-gang", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SmokeDecoyIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "mad-fenton", + "name": "Mad Fenton", + "color": "Red", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 2, + "chargePips": 1, + "healthMax": 300, + "damageMax": 176, + "abilityId": "jump-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkForwardIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "sir-kane-the-insane", + "name": "Sir Kane The Insane", + "color": "Red", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 2, + "rangePips": 1, + "chargePips": 2, + "healthMax": 413, + "damageMax": 225, + "abilityId": "random-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkRandomIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "bobby-big-cheese-marchese", + "name": "Bobby \"Big Cheese\" Marchese", + "color": "Red", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 263, + "damageMax": 225, + "abilityId": "invisibility", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "InvisibilityIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "chef-brennan", + "name": "Chef Brennan", + "color": "Red", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 3, + "chargePips": 1, + "healthMax": 300, + "damageMax": 212, + "abilityId": "heal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "don-hernando", + "name": "Don Hernando", + "color": "Red", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "shifting-bait", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "MatchingDecoyIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "joe-rotten", + "name": "Joe Rotten", + "color": "Red", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 300, + "damageMax": 192, + "abilityId": "jump-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkForwardIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "lil-bo-pumpkins", + "name": "Lil' Bo Pumpkins", + "color": "Red", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "shifting-bait", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "MatchingDecoyIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "murray-andreeves", + "name": "Murray Andreeves", + "color": "Red", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "shifting-bait", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "MatchingDecoyIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "old-leon", + "name": "Old Leon", + "color": "Red", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": null, + "damageMax": null, + "abilityId": "gloom-wall", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomWallIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "ugha", + "name": "Ugha", + "color": "Red", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "heal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "blaze-m-barr", + "name": "Blaze M. Barr", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "firestarter", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "FireIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "butch-lindenburger", + "name": "Butch Lindenburger", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "healing-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealPadIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "captain-archibald", + "name": "Captain Archibald", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "kraken-call", + "faceSprite": "ArchibaldIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "ArchibaldIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "cassius-blackeye-sockem", + "name": "Cassius 'Blackeye' Sockem", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 1, + "chargePips": 1, + "healthMax": 338, + "damageMax": 192, + "abilityId": "jump-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkForwardIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "crooked-sven-robin", + "name": "Crooked Sven Robin", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 2, + "chargePips": 2, + "healthMax": 313, + "damageMax": 212, + "abilityId": "speed-boost-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostPadIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "dr-drewell", + "name": "Dr. Drewell", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "powerful-potions", + "faceSprite": "DrewellIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "DrewellIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "drew-jockstrap-jones", + "name": "Drew \"Jockstrap\" Jones", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 1, + "chargePips": 2, + "healthMax": 375, + "damageMax": 225, + "abilityId": "gloom-wall", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomWallIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "graak", + "name": "Graak", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 2, + "chargePips": 1, + "healthMax": null, + "damageMax": null, + "abilityId": "jump-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "JumpPadIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "lady-e", + "name": "Lady E", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 178, + "abilityId": "hidden-portal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HiddenPortalIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "lumber-zack", + "name": "Lumber Zack", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": null, + "damageMax": null, + "abilityId": "invisibility", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "InvisibilityIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "madame-digamberetto", + "name": "Madame Digamberetto", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 4, + "chargePips": 2, + "healthMax": 338, + "damageMax": 192, + "abilityId": "flight", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "FlightIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "maxine-turbo", + "name": "Maxine Turbo", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "drag-race", + "faceSprite": "HotrodIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "HotrodIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "monsieur-forte", + "name": "Monsieur Forte", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 2, + "chargePips": 3, + "healthMax": 313, + "damageMax": 192, + "abilityId": "gloombrella", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomCeilingIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "samuel-mason", + "name": "Samuel Mason", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 4, + "chargePips": 1, + "healthMax": 338, + "damageMax": 225, + "abilityId": "soul-shot", + "faceSprite": "SamuelMasonIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "SamuelMasonIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "tragico", + "name": "Tragico", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "clown-car", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "ClownCar.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "trucker-dale", + "name": "Trucker Dale", + "color": "Red", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 4, + "chargePips": 1, + "healthMax": 338, + "damageMax": 225, + "abilityId": "gloom-engine", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomEngine.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "alice", + "name": "Alice", + "color": "Yellow", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 5, + "rangePips": 1, + "chargePips": 3, + "healthMax": 263, + "damageMax": 161, + "abilityId": "speed-boost-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostPadIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "diana", + "name": "Diana", + "color": "Yellow", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 4, + "rangePips": 4, + "chargePips": 3, + "healthMax": 263, + "damageMax": 161, + "abilityId": "jump-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "JumpPadIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "james", + "name": "James", + "color": "Yellow", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 4, + "rangePips": 3, + "chargePips": 2, + "healthMax": 375, + "damageMax": 161, + "abilityId": "flight", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "FlightIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "luis-piedrecita", + "name": "Luis Piedrecita", + "color": "Yellow", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 3, + "rangePips": 5, + "chargePips": 3, + "healthMax": 300, + "damageMax": 135, + "abilityId": "ghost-gang", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SmokeDecoyIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "mr-mueller", + "name": "Mr. Mueller", + "color": "Yellow", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 4, + "rangePips": 4, + "chargePips": 3, + "healthMax": 300, + "damageMax": 176, + "abilityId": "random-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkRandomIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "ms-sandri", + "name": "Ms. Sandri", + "color": "Yellow", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 375, + "damageMax": 161, + "abilityId": "jump", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "JumpIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "rufus", + "name": "Rufus", + "color": "Yellow", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "flight", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "FlightIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "wild-thomas", + "name": "Wild Thomas", + "color": "Yellow", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 4, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 161, + "abilityId": "jump", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "JumpIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "zeb-jackson", + "name": "Zeb Jackson", + "color": "Yellow", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 375, + "damageMax": 161, + "abilityId": "shifting-bait", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "MatchingDecoyIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "archibald-the-1st", + "name": "Archibald The 1st", + "color": "Yellow", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 2, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "heal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "don-janko", + "name": "Don Janko", + "color": "Yellow", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 4, + "rangePips": 2, + "chargePips": 2, + "healthMax": 338, + "damageMax": 161, + "abilityId": "jump", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "JumpIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "doug", + "name": "Doug", + "color": "Yellow", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 4, + "rangePips": 2, + "chargePips": 1, + "healthMax": 375, + "damageMax": 161, + "abilityId": "healing-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealPadIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "everett", + "name": "Everett", + "color": "Yellow", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 2, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "tracking-gloom", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HomingShotIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "garfield-gonzalez", + "name": "Garfield Gonzalez", + "color": "Yellow", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 4, + "rangePips": 5, + "chargePips": 1, + "healthMax": 338, + "damageMax": 161, + "abilityId": "jump-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkForwardIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "nanna-borom", + "name": "Nanna Borom", + "color": "Yellow", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 3, + "chargePips": 3, + "healthMax": 338, + "damageMax": 161, + "abilityId": "teleportal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "TeleportToPortalIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "phil", + "name": "Phil", + "color": "Yellow", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 2, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "tracking-gloom", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HomingShotIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "summer", + "name": "Summer", + "color": "Yellow", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 2, + "chargePips": 3, + "healthMax": 300, + "damageMax": 150, + "abilityId": "random-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkRandomIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "timothy", + "name": "Timothy", + "color": "Yellow", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 3, + "rangePips": 2, + "chargePips": 2, + "healthMax": 413, + "damageMax": 176, + "abilityId": "speed-boost", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "dave", + "name": "Dave", + "color": "Yellow", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 413, + "damageMax": 150, + "abilityId": "invisibility", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "InvisibilityIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "ivan-monroe", + "name": "Ivan Monroe", + "color": "Yellow", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 5, + "chargePips": 3, + "healthMax": 300, + "damageMax": 135, + "abilityId": "speed-boost", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "jake-rosebud", + "name": "Jake Rosebud", + "color": "Yellow", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 4, + "rangePips": 4, + "chargePips": 2, + "healthMax": 263, + "damageMax": 176, + "abilityId": "invisibility", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "InvisibilityIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "matt-with-the-propeller-hat", + "name": "Matt with the propeller hat", + "color": "Yellow", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 4, + "rangePips": 4, + "chargePips": 2, + "healthMax": 263, + "damageMax": 176, + "abilityId": "invisibility", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "InvisibilityIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "ollie", + "name": "Ollie", + "color": "Yellow", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 5, + "rangePips": 1, + "chargePips": 2, + "healthMax": 338, + "damageMax": 150, + "abilityId": "speed-boost", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "percy", + "name": "Percy", + "color": "Yellow", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 4, + "rangePips": 4, + "chargePips": 2, + "healthMax": null, + "damageMax": null, + "abilityId": "invisibility", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "InvisibilityIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "pickles", + "name": "Pickles", + "color": "Yellow", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 300, + "damageMax": 150, + "abilityId": "flight", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "FlightIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "professor-iken", + "name": "Professor Iken", + "color": "Yellow", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 5, + "chargePips": 3, + "healthMax": 300, + "damageMax": 135, + "abilityId": "jump-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "JumpPadIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "anomolo", + "name": "Anomolo", + "color": "Yellow", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 4, + "rangePips": 3, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "spew", + "faceSprite": "AnomoloIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "AnomoloIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "chester-the-jester", + "name": "Chester The Jester", + "color": "Yellow", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "heal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "daisy", + "name": "Daisy", + "color": "Yellow", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 4, + "rangePips": 3, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "speed-boost-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostPadIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "harry-cane", + "name": "Harry Cane", + "color": "Yellow", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 5, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 176, + "abilityId": "sonic-boom", + "faceSprite": "AirShowIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "AirShowIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "kreatoru", + "name": "Kreatoru", + "color": "Yellow", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 4, + "rangePips": 3, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "bubble-curtain", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BubbleShieldIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "old-niek", + "name": "Old Niek", + "color": "Yellow", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 3, + "rangePips": 3, + "chargePips": 3, + "healthMax": 338, + "damageMax": 161, + "abilityId": "sentry", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SentryIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "rat-shaun", + "name": "Rat Shaun", + "color": "Yellow", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 4, + "rangePips": 3, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "rat-king", + "faceSprite": "RatkingIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "RatkingIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "the-bawa", + "name": "The Bawa", + "color": "Yellow", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 4, + "rangePips": 4, + "chargePips": 1, + "healthMax": 375, + "damageMax": 176, + "abilityId": "shadowflight", + "faceSprite": "BawaIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "BawaIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "the-duke", + "name": "The Duke", + "color": "Yellow", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 4, + "rangePips": 3, + "chargePips": 2, + "healthMax": 338, + "damageMax": 176, + "abilityId": "random-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkRandomIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "andy-lasky", + "name": "Andy Lasky", + "color": "Blue", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 1, + "rangePips": 5, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "tracking-gloom", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HomingShotIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "annie-bonnie", + "name": "Annie Bonnie", + "color": "Blue", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 2, + "rangePips": 4, + "chargePips": 2, + "healthMax": null, + "damageMax": null, + "abilityId": "tracking-gloom", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HomingShotIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "gary-attric", + "name": "Gary Attric", + "color": "Blue", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 2, + "rangePips": 4, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "tracking-gloom", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HomingShotIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "maiken-hill", + "name": "Maiken Hill", + "color": "Blue", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 1, + "rangePips": 2, + "chargePips": 3, + "healthMax": 413, + "damageMax": 192, + "abilityId": "shifting-bait", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "MatchingDecoyIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "molly-jones", + "name": "Molly Jones", + "color": "Blue", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 413, + "damageMax": 176, + "abilityId": "healing-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealPadIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "ol-elinor", + "name": "Ol' Elinor", + "color": "Blue", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 413, + "damageMax": 176, + "abilityId": "teleportal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "TeleportToPortalIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "ted-big-bun-bixby", + "name": "Ted \"Big Bun\" Bixby", + "color": "Blue", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 413, + "damageMax": 176, + "abilityId": "heal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "wendy-batt", + "name": "Wendy Batt", + "color": "Blue", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 2, + "rangePips": 4, + "chargePips": 1, + "healthMax": 375, + "damageMax": 176, + "abilityId": "gloom-wall", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomWallIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "xiao-wen", + "name": "Xiao Wen", + "color": "Blue", + "rarity": "Common", + "rarityTier": 1, + "speedPips": 1, + "rangePips": 3, + "chargePips": 2, + "healthMax": null, + "damageMax": null, + "abilityId": "gloombrella", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomCeilingIcon.png", + "rarityIcon": null, + "baseVisual": "wisp" + }, + { + "id": "barry-the-bomber", + "name": "Barry The Bomber", + "color": "Blue", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 2, + "rangePips": 5, + "chargePips": 2, + "healthMax": 313, + "damageMax": 225, + "abilityId": "ghost-gang", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SmokeDecoyIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "grandma-winnie", + "name": "Grandma Winnie", + "color": "Blue", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 1, + "rangePips": 4, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "jump-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkForwardIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "grandpa-sam", + "name": "Grandpa Sam", + "color": "Blue", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 1, + "rangePips": 5, + "chargePips": 3, + "healthMax": 338, + "damageMax": 192, + "abilityId": "random-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkRandomIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "mr-tucker", + "name": "Mr. Tucker", + "color": "Blue", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 2, + "rangePips": 2, + "chargePips": 1, + "healthMax": 313, + "damageMax": 161, + "abilityId": "speed-boost-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostPadIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "mr-white", + "name": "Mr. White", + "color": "Blue", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 2, + "rangePips": 3, + "chargePips": 1, + "healthMax": 450, + "damageMax": 176, + "abilityId": "jump", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "JumpIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "nurse-nellie", + "name": "Nurse Nellie", + "color": "Blue", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 1, + "rangePips": 4, + "chargePips": 2, + "healthMax": 338, + "damageMax": 212, + "abilityId": "healing-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "HealPadIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "old-fuller", + "name": "Old Fuller", + "color": "Blue", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 1, + "rangePips": 3, + "chargePips": 2, + "healthMax": 313, + "damageMax": 176, + "abilityId": "sentry", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SentryIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "ron-duke", + "name": "Ron Duke", + "color": "Blue", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 2, + "rangePips": 4, + "chargePips": 3, + "healthMax": 375, + "damageMax": 161, + "abilityId": "shifting-bait", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "MatchingDecoyIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "t-e-anderson", + "name": "T.E. Anderson", + "color": "Blue", + "rarity": "Rare", + "rarityTier": 2, + "speedPips": 2, + "rangePips": 4, + "chargePips": 3, + "healthMax": 375, + "damageMax": 161, + "abilityId": "gloom-wall", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomWallIcon.png", + "rarityIcon": "GhostRarity_Tier2.png", + "baseVisual": "wisp" + }, + { + "id": "alex-boudoun", + "name": "Alex Boudoun", + "color": "Blue", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 2, + "rangePips": 3, + "chargePips": 2, + "healthMax": 375, + "damageMax": 192, + "abilityId": "jump-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "JumpPadIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "belulah-demonay", + "name": "Belulah Demonay", + "color": "Blue", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 3, + "chargePips": 1, + "healthMax": 450, + "damageMax": 192, + "abilityId": "random-blink", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "BlinkRandomIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "joe-snow", + "name": "Joe Snow", + "color": "Blue", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 3, + "chargePips": 3, + "healthMax": 338, + "damageMax": 161, + "abilityId": "sentry", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SentryIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "judge-higgins", + "name": "Judge Higgins", + "color": "Blue", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 3, + "chargePips": 3, + "healthMax": null, + "damageMax": null, + "abilityId": "gloom-wall", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomWallIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "luigi-gelato", + "name": "Luigi Gelato", + "color": "Blue", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 3, + "chargePips": 3, + "healthMax": 338, + "damageMax": 161, + "abilityId": "speed-boost-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostPadIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "morgan-walken", + "name": "Morgan Walken", + "color": "Blue", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 1, + "rangePips": 4, + "chargePips": 2, + "healthMax": 413, + "damageMax": 176, + "abilityId": "teleportal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "TeleportToPortalIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "roy", + "name": "Roy", + "color": "Blue", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 1, + "rangePips": 3, + "chargePips": 2, + "healthMax": 313, + "damageMax": 176, + "abilityId": "ghost-gang", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SmokeDecoyIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "trudy-joy", + "name": "Trudy Joy", + "color": "Blue", + "rarity": "Epic", + "rarityTier": 3, + "speedPips": 3, + "rangePips": 2, + "chargePips": 2, + "healthMax": 450, + "damageMax": 150, + "abilityId": "speed-boost-pad", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SpeedBoostPadIcon.png", + "rarityIcon": "GhostRarity_Tier3.png", + "baseVisual": "wisp" + }, + { + "id": "bart-chaney", + "name": "Bart Chaney", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 4, + "chargePips": 2, + "healthMax": 413, + "damageMax": 192, + "abilityId": "prison-break", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "PrisonbreakIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "beau", + "name": "Beau", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 1, + "rangePips": 1, + "chargePips": 2, + "healthMax": 413, + "damageMax": 176, + "abilityId": "shifting-bait", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "MatchingDecoyIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "flavio", + "name": "Flavio", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 1, + "rangePips": 3, + "chargePips": 2, + "healthMax": 375, + "damageMax": 176, + "abilityId": "teleportal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "TeleportToPortalIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "gary-linemann", + "name": "Gary Linemann", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 1, + "rangePips": 2, + "chargePips": 2, + "healthMax": 450, + "damageMax": 161, + "abilityId": "gloombrella", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomCeilingIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "giacomo", + "name": "Giacomo", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 1, + "rangePips": 3, + "chargePips": 2, + "healthMax": null, + "damageMax": null, + "abilityId": "teleportal", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "TeleportToPortalIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "joe-ishmael", + "name": "Joe Ishmael", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 1, + "rangePips": 4, + "chargePips": 2, + "healthMax": 413, + "damageMax": 176, + "abilityId": "gloom-seeker", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomSeeker.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "magnus-gornason", + "name": "Magnus Gornason", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 1, + "rangePips": 2, + "chargePips": 3, + "healthMax": 375, + "damageMax": 161, + "abilityId": "ghost-gang", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "SmokeDecoyIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "mamali", + "name": "Mamali", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 5, + "chargePips": 1, + "healthMax": 413, + "damageMax": 192, + "abilityId": "root-roof", + "faceSprite": "MamaliIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "MamaliIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "mr-nibs", + "name": "Mr. Nibs", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 4, + "chargePips": 2, + "healthMax": 413, + "damageMax": 192, + "abilityId": "shadow-bears", + "faceSprite": "MrNibsIcon.png", + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": true, + "locations": [], + "abilityIcon": "MrNibsIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "spewer", + "name": "Spewer", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 5, + "chargePips": 1, + "healthMax": 413, + "damageMax": 192, + "abilityId": "recall", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "TempAbilityIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + }, + { + "id": "the-maw", + "name": "The Maw", + "color": "Blue", + "rarity": "Legendary", + "rarityTier": 4, + "speedPips": 2, + "rangePips": 5, + "chargePips": 1, + "healthMax": 413, + "damageMax": 192, + "abilityId": "gloom-snack", + "faceSprite": null, + "fullBodySprite": null, + "webmPath": null, + "webpPath": null, + "arHealth": null, + "hauntValue": null, + "isBoss": null, + "locations": [], + "abilityIcon": "GloomSnackIcon.png", + "rarityIcon": "GhostRarity_Tier4.png", + "baseVisual": "wisp" + } +] diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..fb64245 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,166 @@ + + + + + +Newbury Exhibit — scene workshop + + + +
+
+
Newbury scene workshop
+
+
+ + + + +
+ +
+ +
+ +
+ + + +
+
+ cursor
+ nothing selected +
+
+ +
+
+
World
+
Anchors0
+
Ghosts0
+
Buildings0
+
+ +
+
+
+
+
+
+ +
+ + + + diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 0000000..01d0216 --- /dev/null +++ b/public/js/admin.js @@ -0,0 +1,744 @@ +/* Newbury Exhibit — scene workshop (admin editor) + * + * A 3D authoring GUI over data/scene.json. Edit the table, anchors (AR crests), + * ghost spawns + their motion (hover / patrol path), and building blockers + * (stackable cubes used later for occlusion). Save writes back to the server, + * which backs up the old file and hot-reloads so live viewers update. + * + * Coordinates: world millimetres, origin at the front-left table corner. + * X = length (1500), Z = depth (700), Y = height. Three.js uses metres (×0.001). + */ +import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js'; +import { OrbitControls } from 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js'; + +const MM = 0.001; +const LURE = { + Red: { top: 0xf65151, bottom: 0xff2678 }, + Yellow: { top: 0xb57f0b, bottom: 0xfff35d }, + Blue: { top: 0x529eff, bottom: 0x51eaf1 }, +}; +const COL = { anchor: 0x00e5c0, blocker: 0xc98a4b, sel: 0xffd24a }; + +// ---- editable state (the scene we mutate, then POST) ---------------------- +let scene = null; +let roster = []; // ghost roster for the picker (from /api/ghosts) +let rosterById = new Map(); +let dirty = false; +let selected = null; // { kind:'anchor'|'spawn'|'blocker', id } +let tool = 'select'; +let nextIdN = 1; + +// ---- three.js ------------------------------------------------------------- +const canvas = document.getElementById('c'); +const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); +renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); + +const view3d = new THREE.Scene(); +view3d.background = new THREE.Color(0x070910); + +const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 200); +camera.position.set(0.9, 1.1, 1.7); + +const controls = new OrbitControls(camera, canvas); +controls.enableDamping = true; + +view3d.add(new THREE.AmbientLight(0x8090b0, 0.9)); +const key = new THREE.DirectionalLight(0xffffff, 1.0); +key.position.set(2, 3, 1.5); view3d.add(key); + +// groups so we can rebuild each kind independently +const gTable = new THREE.Group(); view3d.add(gTable); +const gAnchors = new THREE.Group(); view3d.add(gAnchors); +const gSpawns = new THREE.Group(); view3d.add(gSpawns); +const gBlockers = new THREE.Group(); view3d.add(gBlockers); +const gPaths = new THREE.Group(); view3d.add(gPaths); + +const raycaster = new THREE.Raycaster(); +const pointer = new THREE.Vector2(); +let tablePlane = null; // THREE.Plane at Y=0 for drag math + +// map THREE object.uuid -> { kind, id } for picking +const pickMap = new Map(); + +// ---- helpers -------------------------------------------------------------- +const $ = (id) => document.getElementById(id); +const mm2m = (v) => v * MM; +const v3 = (p) => new THREE.Vector3(p.x * MM, p.y * MM, p.z * MM); +function uid(prefix) { return `${prefix}-${Date.now().toString(36)}-${(nextIdN++).toString(36)}`; } +function markDirty() { dirty = true; $('dirty').classList.add('show'); $('saveBtn').disabled = false; } + +function toast(msg, kind = '') { + const t = $('toast'); t.textContent = msg; t.className = 'show ' + kind; + clearTimeout(t._h); t._h = setTimeout(() => (t.className = ''), 2600); +} + +// =========================================================================== +// LOAD +// =========================================================================== +async function load() { + const res = await fetch('/api/scene'); + scene = await res.json(); + // load roster once (optional; picker falls back to free text if missing) + if (!roster.length) { + try { + const rr = await fetch('/api/ghosts'); + roster = await rr.json(); + rosterById = new Map(roster.map((g) => [g.id, g])); + } catch (_) { roster = []; } + } + if (!scene.blockers) scene.blockers = []; + if (!scene.world.buildSize) scene.world.buildSize = { x: 1500, y: 600, z: 700 }; + rebuildAll(); + centerCamera(); + renderPanels(); + updateCounts(); + $('worldMeta').textContent = + `${scene.world.buildSize.x}×${scene.world.buildSize.z}mm · ${scene.anchors.length} anchors · ${scene.spawns.length} ghosts · ${scene.blockers.length} buildings`; +} + +function centerCamera() { + const b = scene.world.buildSize; + controls.target.set(b.x * MM / 2, 0.1, b.z * MM / 2); + controls.update(); +} + +// =========================================================================== +// REBUILD 3D +// =========================================================================== +function rebuildAll() { rebuildTable(); rebuildAnchors(); rebuildSpawns(); rebuildBlockers(); } + +function clearGroup(g) { + pickMapPrune(g); + while (g.children.length) { + const c = g.children.pop(); + c.geometry?.dispose?.(); + if (Array.isArray(c.material)) c.material.forEach((m) => m.dispose?.()); + else c.material?.dispose?.(); + } +} +function pickMapPrune(g) { + g.traverse((o) => pickMap.delete(o.uuid)); +} + +function rebuildTable() { + clearGroup(gTable); + const b = scene.world.buildSize; + const w = b.x * MM, d = b.z * MM; + tablePlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); + + const top = new THREE.Mesh( + new THREE.PlaneGeometry(w, d), + new THREE.MeshStandardMaterial({ color: 0x10131f, roughness: 0.95, side: THREE.DoubleSide }) + ); + top.rotation.x = -Math.PI / 2; + top.position.set(w / 2, 0, d / 2); + gTable.add(top); + + // grid every 100mm + const grid = new THREE.GridHelper(Math.max(w, d), Math.round(Math.max(b.x, b.z) / 100), 0x2a3450, 0x1a2238); + grid.position.set(w / 2, 0.0005, d / 2); + // clip grid to table by scaling (approx): leave as square underlay + gTable.add(grid); + + // origin corner marker + axes (X red-ish along length, Z blue along depth) + const axisLen = 0.12; + gTable.add(axisLine(0, 0, 0, axisLen, 0, 0, 0xff6b6b)); // X + gTable.add(axisLine(0, 0, 0, 0, 0, axisLen, 0x6b8cff)); // Z + const corner = new THREE.Mesh(new THREE.SphereGeometry(0.01, 12, 12), + new THREE.MeshBasicMaterial({ color: 0xffffff })); + gTable.add(corner); +} +function axisLine(x1, y1, z1, x2, y2, z2, color) { + const g = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(x1, y1, z1), new THREE.Vector3(x2, y2, z2)]); + return new THREE.Line(g, new THREE.LineBasicMaterial({ color })); +} + +function rebuildAnchors() { + clearGroup(gAnchors); + for (const a of scene.anchors) { + const s = (a.fiducialSizeMm || 40) * MM; + const isSel = selected?.kind === 'anchor' && selected.id === a.id; + const mesh = new THREE.Mesh( + new THREE.BoxGeometry(s, s * 0.12, s), + new THREE.MeshStandardMaterial({ + color: isSel ? COL.sel : COL.anchor, emissive: isSel ? COL.sel : COL.anchor, + emissiveIntensity: 0.45, + }) + ); + mesh.position.copy(v3(a.position)); + mesh.rotation.y = THREE.MathUtils.degToRad(a.rotationDeg?.y || 0); + gAnchors.add(mesh); + pickMap.set(mesh.uuid, { kind: 'anchor', id: a.id }); + // little post so it reads as a plaque on a stand + const post = new THREE.Mesh(new THREE.CylinderGeometry(0.004, 0.004, a.position.y * MM, 6), + new THREE.MeshStandardMaterial({ color: 0x2a3450 })); + post.position.set(a.position.x * MM, (a.position.y * MM) / 2, a.position.z * MM); + gAnchors.add(post); + } +} + +function rebuildSpawns() { + clearGroup(gSpawns); clearGroup(gPaths); + for (const sp of scene.spawns) { + const isSel = selected?.kind === 'spawn' && selected.id === sp.id; + const lure = LURE[sp.color] || LURE.Blue; + const mat = new THREE.MeshStandardMaterial({ + color: isSel ? COL.sel : lure.bottom, emissive: lure.top, emissiveIntensity: 0.6, + transparent: true, opacity: 0.9, roughness: 0.4, + }); + const mesh = new THREE.Mesh(new THREE.IcosahedronGeometry(0.045, 1), mat); + const home = sp.motion === 'patrol' ? (sp.path?.[0] || { x: 0, y: 0, z: 0 }) : sp.position; + mesh.position.copy(v3(home)); + gSpawns.add(mesh); + pickMap.set(mesh.uuid, { kind: 'spawn', id: sp.id }); + + if (sp.motion === 'patrol' && sp.path?.length > 1) { + const pts = sp.path.map(v3); pts.push(pts[0].clone()); + const line = new THREE.Line( + new THREE.BufferGeometry().setFromPoints(pts), + new THREE.LineBasicMaterial({ color: isSel ? COL.sel : 0x51eaf1, transparent: true, opacity: 0.6 }) + ); + gPaths.add(line); + sp.path.forEach((p, i) => { + const node = new THREE.Mesh(new THREE.SphereGeometry(0.012, 10, 10), + new THREE.MeshBasicMaterial({ color: i === 0 ? 0xffffff : 0x51eaf1 })); + node.position.copy(v3(p)); + gPaths.add(node); + pickMap.set(node.uuid, { kind: 'pathpt', id: sp.id, index: i }); + }); + } else if (sp.motion === 'hover' && sp.hoverRadiusMm) { + // hover ring + const r = sp.hoverRadiusMm * MM; + const ring = new THREE.Mesh( + new THREE.RingGeometry(r * 0.96, r, 32), + new THREE.MeshBasicMaterial({ color: 0x51eaf1, transparent: true, opacity: 0.3, side: THREE.DoubleSide }) + ); + ring.rotation.x = -Math.PI / 2; + ring.position.copy(v3(sp.position)); + gPaths.add(ring); + } + } +} + +function rebuildBlockers() { + clearGroup(gBlockers); + for (const bl of scene.blockers) { + const isSel = selected?.kind === 'blocker' && selected.id === bl.id; + const sz = bl.size; + const mesh = new THREE.Mesh( + new THREE.BoxGeometry(sz.x * MM, sz.y * MM, sz.z * MM), + new THREE.MeshStandardMaterial({ + color: isSel ? COL.sel : COL.blocker, transparent: true, opacity: isSel ? 0.55 : 0.4, + roughness: 0.8, + }) + ); + // position is the CENTRE of the box base footprint; Y is base height (usually 0) + mesh.position.set( + (bl.position.x + sz.x / 2) * MM, + (bl.position.y + sz.y / 2) * MM, + (bl.position.z + sz.z / 2) * MM + ); + gBlockers.add(mesh); + pickMap.set(mesh.uuid, { kind: 'blocker', id: bl.id }); + // wireframe edge for clarity + const edges = new THREE.LineSegments( + new THREE.EdgesGeometry(mesh.geometry), + new THREE.LineBasicMaterial({ color: isSel ? COL.sel : 0xe0a868, transparent: true, opacity: 0.7 }) + ); + edges.position.copy(mesh.position); + gBlockers.add(edges); + } +} + +// =========================================================================== +// PICKING + DRAG +// =========================================================================== +let dragging = null; // { kind, id, index?, offset:Vector3 } + +function setPointer(e) { + const r = canvas.getBoundingClientRect(); + pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1; + pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1; +} + +function intersectTable() { + raycaster.setFromCamera(pointer, camera); + const hit = new THREE.Vector3(); + raycaster.ray.intersectPlane(tablePlane, hit); + return hit; // metres +} + +function pickObject() { + raycaster.setFromCamera(pointer, camera); + const hits = raycaster.intersectObjects( + [...gAnchors.children, ...gSpawns.children, ...gBlockers.children, ...gPaths.children], false); + for (const h of hits) { + const ref = pickMap.get(h.object.uuid); + if (ref) return ref; + } + return null; +} + +canvas.addEventListener('pointerdown', (e) => { + setPointer(e); + // tool-based add + if (tool !== 'select') { addAtCursor(); return; } + const ref = pickObject(); + if (ref) { + if (ref.kind === 'pathpt') { + selectEntity('spawn', ref.id); + dragging = { kind: 'pathpt', id: ref.id, index: ref.index }; + } else { + selectEntity(ref.kind, ref.id); + dragging = { kind: ref.kind, id: ref.id }; + } + controls.enabled = false; + } +}); + +canvas.addEventListener('pointermove', (e) => { + setPointer(e); + const hit = intersectTable(); + if (hit) $('cursorPos').textContent = `${Math.round(hit.x / MM)}, ${Math.round(hit.z / MM)} mm`; + if (!dragging || !hit) return; + const x = Math.round(hit.x / MM), z = Math.round(hit.z / MM); + applyDrag(dragging, x, z); +}); + +window.addEventListener('pointerup', () => { + if (dragging) { dragging = null; controls.enabled = true; renderPanels(); } +}); + +function applyDrag(d, x, z) { + if (d.kind === 'anchor') { + const a = scene.anchors.find((o) => o.id === d.id); + a.position.x = clampX(x); a.position.z = clampZ(z); rebuildAnchors(); + } else if (d.kind === 'blocker') { + const bl = scene.blockers.find((o) => o.id === d.id); + bl.position.x = clampX(x - bl.size.x / 2); bl.position.z = clampZ(z - bl.size.z / 2); rebuildBlockers(); + } else if (d.kind === 'spawn') { + const sp = scene.spawns.find((o) => o.id === d.id); + if (sp.motion === 'hover') { sp.position.x = x; sp.position.z = z; } + else if (sp.path?.length) { const dx = x - sp.path[0].x, dz = z - sp.path[0].z; + sp.path.forEach((p) => { p.x += dx; p.z += dz; }); } + rebuildSpawns(); + } else if (d.kind === 'pathpt') { + const sp = scene.spawns.find((o) => o.id === d.id); + sp.path[d.index].x = x; sp.path[d.index].z = z; rebuildSpawns(); + } + markDirty(); +} +function clampX(x) { return Math.max(0, Math.min(scene.world.buildSize.x, x)); } +function clampZ(z) { return Math.max(0, Math.min(scene.world.buildSize.z, z)); } + +function addAtCursor() { + const hit = intersectTable(); if (!hit) return; + const x = Math.round(clampX(hit.x / MM)), z = Math.round(clampZ(hit.z / MM)); + if (tool === 'addSpawn') { + const sp = { id: uid('spawn'), ghostId: '', color: 'Blue', label: 'New ghost', motion: 'hover', + position: { x, y: 180, z }, hoverRadiusMm: 50, hoverPeriodS: 6, bobAmplitudeMm: 30, bobPeriodS: 3, yawDegPerS: 10 }; + scene.spawns.push(sp); rebuildSpawns(); selectEntity('spawn', sp.id); setTool('select'); + } else if (tool === 'addBlocker') { + const bl = { id: uid('blk'), label: 'Building', position: { x, y: 0, z }, size: { x: 120, y: 100, z: 120 } }; + scene.blockers.push(bl); rebuildBlockers(); selectEntity('blocker', bl.id); setTool('select'); + } else if (tool === 'addAnchor') { + const n = scene.anchors.length; + const a = { id: uid('anchor'), label: `Anchor ${n + 1}`, fiducialType: 'aruco', fiducialId: n, + fiducialSizeMm: 96, position: { x, y: 30, z }, rotationDeg: { x: 0, y: 0, z: 0 } }; + scene.anchors.push(a); rebuildAnchors(); selectEntity('anchor', a.id); setTool('select'); + } + markDirty(); updateCounts(); +} + +// =========================================================================== +// SELECTION + PANELS +// =========================================================================== +function selectEntity(kind, id) { + selected = { kind, id }; + rebuildAnchors(); rebuildSpawns(); rebuildBlockers(); + // switch to the relevant tab + switchTab(kind === 'anchor' ? 'anchors' : kind === 'spawn' ? 'spawns' : 'blockers'); + renderPanels(); + const obj = kind === 'anchor' ? scene.anchors.find((o) => o.id === id) + : kind === 'spawn' ? scene.spawns.find((o) => o.id === id) + : scene.blockers.find((o) => o.id === id); + $('selInfo').innerHTML = obj ? `${kind}: ${obj.label || obj.id}` : 'nothing selected'; +} + +function updateCounts() { + $('nAnchors').textContent = scene.anchors.length; + $('nSpawns').textContent = scene.spawns.length; + $('nBlockers').textContent = scene.blockers.length; +} + +// ---- panel rendering ------------------------------------------------------ +function renderPanels() { renderWorld(); renderAnchors(); renderSpawns(); renderBlockers(); } + +function numRow(label, value, oninput, opts = {}) { + const wrap = document.createElement('div'); wrap.className = 'row'; + const l = document.createElement('label'); l.textContent = label; wrap.appendChild(l); + const i = document.createElement('input'); i.type = 'number'; i.value = value; + if (opts.step) i.step = opts.step; if (opts.min != null) i.min = opts.min; + i.addEventListener('input', () => oninput(parseFloat(i.value))); + wrap.appendChild(i); return wrap; +} +function xyzRow(label, obj, keys, onchange, opts = {}) { + const wrap = document.createElement('div'); wrap.className = 'row'; + const l = document.createElement('label'); l.textContent = label; wrap.appendChild(l); + const box = document.createElement('div'); box.className = 'xyz'; + for (const k of keys) { + const ax = document.createElement('div'); ax.className = 'axis'; ax.dataset.a = k; + const i = document.createElement('input'); i.type = 'number'; i.value = Math.round(obj[k]); + if (opts.step) i.step = opts.step; + i.addEventListener('input', () => { obj[k] = parseFloat(i.value) || 0; onchange(); }); + ax.appendChild(i); box.appendChild(ax); + } + wrap.appendChild(box); return wrap; +} +function textRow(label, value, oninput) { + const wrap = document.createElement('div'); wrap.className = 'row'; + const l = document.createElement('label'); l.textContent = label; wrap.appendChild(l); + const i = document.createElement('input'); i.type = 'text'; i.value = value || ''; + i.addEventListener('input', () => oninput(i.value)); + wrap.appendChild(i); return wrap; +} +function selectRow(label, value, options, oninput) { + const wrap = document.createElement('div'); wrap.className = 'row'; + const l = document.createElement('label'); l.textContent = label; wrap.appendChild(l); + const s = document.createElement('select'); + for (const o of options) { const op = document.createElement('option'); op.value = o; op.textContent = o; if (o === value) op.selected = true; s.appendChild(op); } + s.addEventListener('change', () => oninput(s.value)); + wrap.appendChild(s); return wrap; +} + +function renderWorld() { + const p = $('panel-world'); p.innerHTML = ''; + const h = document.createElement('div'); h.className = 'section-h'; h.textContent = 'Table dimensions (mm)'; p.appendChild(h); + p.appendChild(xyzRow('Build size', scene.world.buildSize, ['x', 'y', 'z'], () => { rebuildTable(); centerCamera(); markDirty(); updateMeta(); })); + const hint = document.createElement('div'); hint.className = 'hint'; + hint.textContent = 'X = length, Z = depth, Y = max ghost height. Max display 700 × 1500 mm. Origin is the front-left corner; all positions measured from there.'; + p.appendChild(hint); + + const h2 = document.createElement('div'); h2.className = 'section-h'; h2.textContent = 'Origin'; p.appendChild(h2); + const o = document.createElement('div'); o.className = 'hint'; + o.textContent = scene.world.origin || 'front-left corner of baseplate, table surface = Y0'; + p.appendChild(o); +} +function updateMeta() { + $('worldMeta').textContent = + `${scene.world.buildSize.x}×${scene.world.buildSize.z}mm · ${scene.anchors.length} anchors · ${scene.spawns.length} ghosts · ${scene.blockers.length} buildings`; +} + +function listItem({ dot, name, tag, sub, sel, onclick, ondel }) { + const el = document.createElement('div'); el.className = 'list-item' + (sel ? ' sel' : ''); + const top = document.createElement('div'); top.className = 'li-top'; + if (dot) { const d = document.createElement('span'); d.className = 'li-dot'; d.style.background = dot; top.appendChild(d); } + const nm = document.createElement('span'); nm.className = 'li-name'; nm.textContent = name; top.appendChild(nm); + if (tag) { const t = document.createElement('span'); t.className = 'li-tag'; t.textContent = tag; top.appendChild(t); } + el.appendChild(top); + if (sub) { const s = document.createElement('div'); s.className = 'li-sub'; s.textContent = sub; el.appendChild(s); } + el.addEventListener('click', onclick); + return el; +} + +function hexCss(n) { return '#' + n.toString(16).padStart(6, '0'); } + +function renderAnchors() { + const p = $('panel-anchors'); p.innerHTML = ''; + const add = document.createElement('button'); add.className = 'btn-add'; add.textContent = '+ Add anchor (or click ◎ then the table)'; + add.addEventListener('click', () => { setTool('addAnchor'); toast('Click on the table to place the anchor'); }); + p.appendChild(add); + if (!scene.anchors.length) { const e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No anchors yet.'; p.appendChild(e); return; } + + for (const a of scene.anchors) { + const sel = selected?.kind === 'anchor' && selected.id === a.id; + p.appendChild(listItem({ + dot: hexCss(COL.anchor), name: a.label || a.id, tag: `id ${a.fiducialId}`, + sub: `${Math.round(a.position.x)}, ${Math.round(a.position.z)} mm · ${a.fiducialSizeMm}mm`, + sel, onclick: () => selectEntity('anchor', a.id), + })); + if (sel) p.appendChild(anchorEditor(a)); + } +} +function anchorEditor(a) { + const box = document.createElement('div'); box.style.cssText = 'padding:4px 2px 12px'; + box.appendChild(textRow('Label', a.label, (v) => { a.label = v; markDirty(); renderAnchorsSoft(); })); + box.appendChild(selectRow('Marker', a.fiducialType || 'aruco', ['aruco', 'apriltag'], (v) => { a.fiducialType = v; markDirty(); })); + box.appendChild(numRow('Marker id', a.fiducialId, (v) => { a.fiducialId = v | 0; markDirty(); renderAnchorsSoft(); }, { step: 1, min: 0 })); + box.appendChild(numRow('Size (mm)', a.fiducialSizeMm, (v) => { a.fiducialSizeMm = v; rebuildAnchors(); markDirty(); }, { step: 1 })); + box.appendChild(xyzRow('Pos (mm)', a.position, ['x', 'y', 'z'], () => { rebuildAnchors(); markDirty(); })); + box.appendChild(numRow('Yaw (°)', a.rotationDeg?.y || 0, (v) => { a.rotationDeg = a.rotationDeg || { x: 0, y: 0, z: 0 }; a.rotationDeg.y = v; rebuildAnchors(); markDirty(); }, { step: 1 })); + const del = document.createElement('button'); del.className = 'btn-del'; del.textContent = 'Delete anchor'; + del.style.marginTop = '6px'; + del.addEventListener('click', () => { scene.anchors = scene.anchors.filter((o) => o.id !== a.id); selected = null; rebuildAnchors(); renderAnchors(); updateCounts(); markDirty(); }); + box.appendChild(del); + return box; +} +function renderAnchorsSoft() { updateCounts(); updateMeta(); } + +function renderSpawns() { + const p = $('panel-spawns'); p.innerHTML = ''; + const add = document.createElement('button'); add.className = 'btn-add'; add.textContent = '+ Add ghost (or click 👻 then the table)'; + add.addEventListener('click', () => { setTool('addSpawn'); toast('Click on the table to place the ghost'); }); + p.appendChild(add); + if (!scene.spawns.length) { const e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No ghosts yet.'; p.appendChild(e); return; } + + for (const sp of scene.spawns) { + const sel = selected?.kind === 'spawn' && selected.id === sp.id; + const lure = LURE[sp.color] || LURE.Blue; + p.appendChild(listItem({ + dot: hexCss(lure.bottom), name: sp.label || sp.id, tag: sp.motion, + sub: sp.ghostId ? sp.ghostId : '(no ghost id)', + sel, onclick: () => selectEntity('spawn', sp.id), + })); + if (sel) p.appendChild(spawnEditor(sp)); + } +} +// Searchable ghost picker: type to filter 111 ghosts, pick one -> auto-fills +// ghostId + lure colour + boss flag. Falls back to free text if no roster. +function ghostPicker(sp, onPick) { + const wrap = document.createElement('div'); wrap.className = 'row'; wrap.style.position = 'relative'; + const l = document.createElement('label'); l.textContent = 'Ghost'; wrap.appendChild(l); + + const field = document.createElement('div'); field.style.cssText = 'flex:1;min-width:0;position:relative;'; + const input = document.createElement('input'); input.type = 'text'; input.placeholder = 'search ghosts…'; + const cur = rosterById.get(sp.ghostId); + input.value = cur ? cur.name : (sp.ghostId || ''); + input.style.cssText = 'width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font-size:12px;font-family:var(--mono);'; + field.appendChild(input); + + if (!roster.length) { + // no roster available — behave as plain free-text id + input.addEventListener('input', () => { sp.ghostId = input.value; markDirty(); }); + wrap.appendChild(field); return wrap; + } + + const menu = document.createElement('div'); + menu.style.cssText = 'position:absolute;left:0;right:0;top:calc(100% + 3px);z-index:20;max-height:230px;overflow-y:auto;background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:0 12px 30px rgba(0,0,0,0.5);display:none;'; + field.appendChild(menu); + + const rarityDot = { Common: '#7f8aa3', Rare: '#5ad1ff', Epic: '#c07bff', Legendary: '#ffb038' }; + function render(filter) { + const q = filter.trim().toLowerCase(); + const matches = roster.filter((g) => !q || g.name.toLowerCase().includes(q) || g.id.includes(q) || g.color.toLowerCase().startsWith(q)).slice(0, 40); + menu.innerHTML = ''; + if (!matches.length) { menu.style.display = 'none'; return; } + for (const g of matches) { + const it = document.createElement('div'); + it.style.cssText = 'display:flex;align-items:center;gap:8px;padding:7px 9px;cursor:pointer;font-size:12px;border-bottom:1px solid rgba(255,255,255,0.03);'; + it.onmouseenter = () => (it.style.background = 'rgba(255,255,255,0.05)'); + it.onmouseleave = () => (it.style.background = 'transparent'); + const cdot = document.createElement('span'); cdot.style.cssText = `width:9px;height:9px;border-radius:50%;flex-shrink:0;background:${hexCss(LURE[g.color].bottom)};`; + const nm = document.createElement('span'); nm.textContent = g.name; nm.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; + const meta = document.createElement('span'); meta.style.cssText = `font-family:var(--mono);font-size:10px;color:${rarityDot[g.rarity] || 'var(--dim)'};`; + meta.textContent = (g.isBoss ? '★ ' : '') + g.rarity; + it.append(cdot, nm, meta); + it.addEventListener('mousedown', (e) => { // mousedown so it fires before blur + e.preventDefault(); + sp.ghostId = g.id; sp.color = g.color; + input.value = g.name; menu.style.display = 'none'; + onPick(g); markDirty(); + }); + menu.appendChild(it); + } + menu.style.display = 'block'; + } + input.addEventListener('focus', () => render('')); + input.addEventListener('input', () => { sp.ghostId = input.value; render(input.value); }); + input.addEventListener('blur', () => setTimeout(() => (menu.style.display = 'none'), 120)); + + wrap.appendChild(field); return wrap; +} + +function spawnEditor(sp) { + const box = document.createElement('div'); box.style.cssText = 'padding:4px 2px 12px'; + box.appendChild(textRow('Label', sp.label, (v) => { sp.label = v; markDirty(); })); + box.appendChild(ghostPicker(sp, (g) => { + // auto-fill: colour follows ghost; label defaults to ghost name if blank/placeholder + if (!sp.label || sp.label === 'New ghost') sp.label = g.name; + rebuildSpawns(); renderSpawns(); + })); + + // colour swatches + const cr = document.createElement('div'); cr.className = 'row'; + const cl = document.createElement('label'); cl.textContent = 'Lure'; cr.appendChild(cl); + const sws = document.createElement('div'); sws.className = 'swatches'; + for (const key of ['Red', 'Yellow', 'Blue']) { + const s = document.createElement('div'); s.className = 'sw' + (sp.color === key ? ' sel' : ''); + s.style.background = hexCss(LURE[key].bottom); + s.addEventListener('click', () => { sp.color = key; rebuildSpawns(); renderSpawns(); markDirty(); }); + sws.appendChild(s); + } + cr.appendChild(sws); box.appendChild(cr); + + box.appendChild(selectRow('Motion', sp.motion, ['hover', 'patrol'], (v) => { + sp.motion = v; + if (v === 'patrol' && !sp.path) { + const base = sp.position || { x: 200, y: 200, z: 200 }; + sp.path = [{ ...base }, { x: base.x + 200, y: base.y, z: base.z }, { x: base.x + 200, y: base.y, z: base.z + 150 }]; + sp.patrolPeriodS = sp.patrolPeriodS || 16; sp.faceTravel = sp.faceTravel ?? true; + } + if (v === 'hover' && !sp.position) { + sp.position = sp.path?.[0] ? { ...sp.path[0] } : { x: 200, y: 200, z: 200 }; + sp.hoverRadiusMm = sp.hoverRadiusMm ?? 50; sp.hoverPeriodS = sp.hoverPeriodS ?? 6; + } + rebuildSpawns(); renderSpawns(); markDirty(); + })); + + if (sp.motion === 'hover') { + box.appendChild(xyzRow('Pos (mm)', sp.position, ['x', 'y', 'z'], () => { rebuildSpawns(); markDirty(); })); + box.appendChild(numRow('Hover r (mm)', sp.hoverRadiusMm ?? 0, (v) => { sp.hoverRadiusMm = v; rebuildSpawns(); markDirty(); })); + box.appendChild(numRow('Hover period s', sp.hoverPeriodS ?? 6, (v) => { sp.hoverPeriodS = v; markDirty(); }, { step: 0.5 })); + box.appendChild(numRow('Yaw °/s', sp.yawDegPerS ?? 0, (v) => { sp.yawDegPerS = v; markDirty(); }, { step: 1 })); + } else { + const h = document.createElement('div'); h.className = 'section-h'; h.textContent = 'Patrol path (mm)'; box.appendChild(h); + sp.path.forEach((pt, i) => { + const row = document.createElement('div'); row.className = 'pathpt'; + const idx = document.createElement('span'); idx.className = 'idx'; idx.textContent = i; row.appendChild(idx); + const grp = xyzRow('', pt, ['x', 'y', 'z'], () => { rebuildSpawns(); markDirty(); }); + grp.querySelector('label').remove(); grp.style.flex = '1'; grp.style.margin = '0'; + row.appendChild(grp); + if (sp.path.length > 2) { + const rm = document.createElement('button'); rm.className = 'btn-sm'; rm.textContent = '×'; + rm.addEventListener('click', () => { sp.path.splice(i, 1); rebuildSpawns(); renderSpawns(); markDirty(); }); + row.appendChild(rm); + } + box.appendChild(row); + }); + const addPt = document.createElement('button'); addPt.className = 'btn-sm'; addPt.textContent = '+ point'; + addPt.addEventListener('click', () => { const last = sp.path[sp.path.length - 1]; sp.path.push({ x: last.x + 100, y: last.y, z: last.z }); rebuildSpawns(); renderSpawns(); markDirty(); }); + box.appendChild(addPt); + box.appendChild(numRow('Loop period s', sp.patrolPeriodS ?? 16, (v) => { sp.patrolPeriodS = v; markDirty(); }, { step: 0.5 })); + box.appendChild(selectRow('Face travel', String(sp.faceTravel ?? true), ['true', 'false'], (v) => { sp.faceTravel = v === 'true'; markDirty(); })); + } + box.appendChild(numRow('Bob amp (mm)', sp.bobAmplitudeMm ?? 0, (v) => { sp.bobAmplitudeMm = v; markDirty(); })); + box.appendChild(numRow('Bob period s', sp.bobPeriodS ?? 3, (v) => { sp.bobPeriodS = v; markDirty(); }, { step: 0.5 })); + + const del = document.createElement('button'); del.className = 'btn-del'; del.textContent = 'Delete ghost'; del.style.marginTop = '6px'; + del.addEventListener('click', () => { scene.spawns = scene.spawns.filter((o) => o.id !== sp.id); selected = null; rebuildSpawns(); renderSpawns(); updateCounts(); markDirty(); }); + box.appendChild(del); + return box; +} + +function renderBlockers() { + const p = $('panel-blockers'); p.innerHTML = ''; + const add = document.createElement('button'); add.className = 'btn-add'; add.textContent = '+ Add building (or click ▦ then the table)'; + add.addEventListener('click', () => { setTool('addBlocker'); toast('Click on the table to place the building'); }); + p.appendChild(add); + const hint = document.createElement('div'); hint.className = 'hint'; + hint.textContent = 'Buildings are simple boxes used to hide ghosts behind them (occlusion). Stack or place several to approximate a structure. Position is the footprint corner; size is W×H×D.'; + p.appendChild(hint); + if (!scene.blockers.length) { const e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No buildings yet.'; p.appendChild(e); return; } + + for (const bl of scene.blockers) { + const sel = selected?.kind === 'blocker' && selected.id === bl.id; + p.appendChild(listItem({ + dot: hexCss(COL.blocker), name: bl.label || bl.id, tag: `${bl.size.x}×${bl.size.y}×${bl.size.z}`, + sub: `at ${Math.round(bl.position.x)}, ${Math.round(bl.position.z)} mm`, + sel, onclick: () => selectEntity('blocker', bl.id), + })); + if (sel) p.appendChild(blockerEditor(bl)); + } +} +function blockerEditor(bl) { + const box = document.createElement('div'); box.style.cssText = 'padding:4px 2px 12px'; + box.appendChild(textRow('Label', bl.label, (v) => { bl.label = v; markDirty(); })); + box.appendChild(xyzRow('Corner (mm)', bl.position, ['x', 'y', 'z'], () => { rebuildBlockers(); markDirty(); })); + box.appendChild(xyzRow('Size (mm)', bl.size, ['x', 'y', 'z'], () => { rebuildBlockers(); markDirty(); })); + const dup = document.createElement('button'); dup.className = 'btn-sm'; dup.textContent = 'Duplicate (stack +Y)'; + dup.addEventListener('click', () => { + const copy = JSON.parse(JSON.stringify(bl)); copy.id = uid('blk'); + copy.position.y = bl.position.y + bl.size.y; // stack on top + copy.label = (bl.label || 'Building') + ' (stack)'; + scene.blockers.push(copy); rebuildBlockers(); selectEntity('blocker', copy.id); renderBlockers(); updateCounts(); markDirty(); + }); + box.appendChild(dup); + const del = document.createElement('button'); del.className = 'btn-del'; del.textContent = 'Delete building'; del.style.marginLeft = '6px'; + del.addEventListener('click', () => { scene.blockers = scene.blockers.filter((o) => o.id !== bl.id); selected = null; rebuildBlockers(); renderBlockers(); updateCounts(); markDirty(); }); + box.appendChild(del); + return box; +} + +// =========================================================================== +// TABS + TOOLS +// =========================================================================== +function switchTab(name) { + document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === name)); + document.querySelectorAll('.panel').forEach((pl) => pl.classList.toggle('active', pl.id === 'panel-' + name)); +} +document.querySelectorAll('.tab').forEach((t) => t.addEventListener('click', () => switchTab(t.dataset.tab))); + +function setTool(name) { + tool = name; + document.querySelectorAll('#toolbar button').forEach((b) => b.classList.toggle('active', b.dataset.tool === name)); + canvas.style.cursor = name === 'select' ? 'default' : 'crosshair'; +} +document.querySelectorAll('#toolbar button').forEach((b) => b.addEventListener('click', () => setTool(b.dataset.tool))); + +window.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return; + if (e.key === 'v') setTool('select'); + if (e.key === 'g') setTool('addSpawn'); + if (e.key === 'b') setTool('addBlocker'); + if (e.key === 'a') setTool('addAnchor'); + if (e.key === 'Escape') { selected = null; rebuildAll(); renderPanels(); $('selInfo').textContent = 'nothing selected'; } + if ((e.key === 'Delete' || e.key === 'Backspace') && selected) { + const k = selected.kind; + if (k === 'spawn') scene.spawns = scene.spawns.filter((o) => o.id !== selected.id); + if (k === 'blocker') scene.blockers = scene.blockers.filter((o) => o.id !== selected.id); + if (k === 'anchor') scene.anchors = scene.anchors.filter((o) => o.id !== selected.id); + selected = null; rebuildAll(); renderPanels(); updateCounts(); markDirty(); + } +}); + +// =========================================================================== +// SAVE / EXPORT / RELOAD +// =========================================================================== +$('saveBtn').addEventListener('click', async () => { + $('saveBtn').disabled = true; + try { + const res = await fetch('/api/scene', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(scene), + }); + const j = await res.json(); + if (j.ok) { + dirty = false; $('dirty').classList.remove('show'); + toast(`Saved · ${j.spawns} ghosts, ${j.anchors} anchors, ${j.blockers} buildings`, 'ok'); + } else { toast('Save failed: ' + j.error, 'err'); $('saveBtn').disabled = false; } + } catch (e) { toast('Save error: ' + e.message, 'err'); $('saveBtn').disabled = false; } +}); + +$('exportBtn').addEventListener('click', () => { + const blob = new Blob([JSON.stringify(scene, null, 2)], { type: 'application/json' }); + const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'scene.json'; + a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); +}); + +$('reloadBtn').addEventListener('click', async () => { + if (dirty && !confirm('Discard unsaved changes and reload from server?')) return; + selected = null; dirty = false; $('dirty').classList.remove('show'); $('saveBtn').disabled = true; + await load(); + toast('Reloaded from server'); +}); + +window.addEventListener('beforeunload', (e) => { if (dirty) { e.preventDefault(); e.returnValue = ''; } }); + +// =========================================================================== +// RENDER LOOP + RESIZE +// =========================================================================== +function resize() { + const r = canvas.getBoundingClientRect(); + renderer.setSize(r.width, r.height, false); + camera.aspect = r.width / r.height; camera.updateProjectionMatrix(); +} +window.addEventListener('resize', resize); + +function animate() { + requestAnimationFrame(animate); + controls.update(); + renderer.render(view3d, camera); +} + +// boot +resize(); +load().then(() => { setTool('select'); animate(); }); diff --git a/server/sync-server.js b/server/sync-server.js index c418fcf..bd87991 100644 --- a/server/sync-server.js +++ b/server/sync-server.js @@ -2,7 +2,7 @@ import 'dotenv/config'; import express from 'express'; import { createServer } from 'http'; import { WebSocketServer } from 'ws'; -import { readFileSync } from 'fs'; +import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; @@ -19,6 +19,20 @@ function loadScene() { } let scene = loadScene(); +// ---- Ghost roster (for the admin picker) --------------------------------- +// Full enriched roster lives in data/ghosts.json. We serve a trimmed view with +// just what the scene editor needs to pick a ghost and auto-fill its colour. +let roster = []; +try { + const rraw = readFileSync(join(ROOT, 'data', 'ghosts.json'), 'utf-8'); + roster = JSON.parse(rraw).map((g) => ({ + id: g.id, name: g.name, color: g.color, rarity: g.rarity, + rarityTier: g.rarityTier, isBoss: !!g.isBoss, ability: g.abilityId, + })); +} catch (_) { + roster = []; // roster optional; editor falls back to free-text id +} + // ---- Motion solvers ------------------------------------------------------ // Every solver takes (spawn, tSeconds) and returns { x,y,z, yawDeg }. // Positions are in world millimetres, matching scene.json. @@ -95,22 +109,60 @@ function round(n) { // ---- HTTP + static ------------------------------------------------------- const app = express(); +app.use(express.json({ limit: '4mb' })); // for the admin save endpoint app.use(express.static(join(ROOT, 'public'))); -// Scene metadata (anchors + spawn definitions) for clients to align/render. +// Full scene for the admin editor (everything) and clients (anchors+spawns). app.get('/api/scene', (_req, res) => { - res.json({ - world: scene.world, - anchors: scene.anchors, - spawns: scene.spawns.map((s) => ({ - id: s.id, - ghostId: s.ghostId, - label: s.label, - motion: s.motion, - })), - }); + res.json(scene); }); +// Ghost roster for the admin picker (trimmed). +app.get('/api/ghosts', (_req, res) => { + res.json(roster); +}); + +// Admin save: validate, back up the old file, write, hot-reload. +app.post('/api/scene', (req, res) => { + const incoming = req.body; + const err = validateScene(incoming); + if (err) return res.status(400).json({ ok: false, error: err }); + try { + const dataDir = join(ROOT, 'data'); + const target = join(dataDir, 'scene.json'); + // timestamped backup so a bad save is always recoverable + const backupDir = join(dataDir, 'backups'); + if (!existsSync(backupDir)) mkdirSync(backupDir, { recursive: true }); + if (existsSync(target)) { + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + copyFileSync(target, join(backupDir, `scene-${stamp}.json`)); + } + writeFileSync(target, JSON.stringify(incoming, null, 2), 'utf-8'); + scene = incoming; // hot-reload: live ghosts update immediately + broadcastHello(); // push new anchors/world to connected viewers + res.json({ ok: true, spawns: scene.spawns.length, anchors: scene.anchors.length, blockers: (scene.blockers || []).length }); + } catch (e) { + res.status(500).json({ ok: false, error: String(e.message || e) }); + } +}); + +// Minimal schema guard so a malformed save can't crash the motion loop. +function validateScene(s) { + if (!s || typeof s !== 'object') return 'scene must be an object'; + if (!s.world || !s.world.buildSize) return 'missing world.buildSize'; + if (!Array.isArray(s.anchors)) return 'anchors must be an array'; + if (!Array.isArray(s.spawns)) return 'spawns must be an array'; + if (s.blockers && !Array.isArray(s.blockers)) return 'blockers must be an array'; + for (const sp of s.spawns) { + if (!sp.id || !sp.motion) return `spawn missing id/motion: ${JSON.stringify(sp).slice(0, 80)}`; + if (sp.motion === 'patrol' && (!Array.isArray(sp.path) || sp.path.length < 2)) { + return `patrol spawn "${sp.id}" needs a path of >=2 points`; + } + if (sp.motion === 'hover' && !sp.position) return `hover spawn "${sp.id}" needs a position`; + } + return null; +} + app.get('/api/health', (_req, res) => { res.json({ ok: true, viewers: wss ? wss.clients.size : 0, tickHz: TICK_HZ }); }); @@ -120,9 +172,25 @@ const httpServer = createServer(app); // ---- WebSocket broadcast ------------------------------------------------- const wss = new WebSocketServer({ server: httpServer, path: '/sync' }); +function helloPayload() { + return JSON.stringify({ + type: 'hello', + world: scene.world, + anchors: scene.anchors, + blockers: scene.blockers || [], + }); +} + +function broadcastHello() { + const payload = helloPayload(); + for (const client of wss.clients) { + if (client.readyState === 1) client.send(payload); + } +} + wss.on('connection', (socket) => { // Send scene + immediate snapshot on connect so a new phone is instantly aligned. - socket.send(JSON.stringify({ type: 'hello', world: scene.world, anchors: scene.anchors })); + socket.send(helloPayload()); socket.send(JSON.stringify(buildSnapshot())); }); diff --git a/upload-to-gitea.bat b/upload-to-gitea.bat index 62c6d2c..3de5e74 100644 --- a/upload-to-gitea.bat +++ b/upload-to-gitea.bat @@ -1,48 +1,73 @@ @echo off REM ============================================================ REM Newbury Exhibit -> Gitea uploader -REM Wipe-and-re-clone _deploy checkout, copy tree, commit, push. -REM Mirrors the newbury-nights deploy pattern. +REM _deploy checkout lives OUTSIDE the project (sibling folder) +REM so it can never copy into itself (no more nested copies). +REM Default HTTPS via DNS; auto-fallback to direct LAN. REM ============================================================ setlocal enabledelayedexpansion set GITEA_HOST=gitea.hideawaygaming.com.au +set LAN_URL=https://gitea.hideawaygaming.com.au set OWNER=jessikitty set REPO=newbury-exhibit set BRANCH=dev -set SRC=%~dp0 + +REM SRC = this script's folder (the project). Strip trailing backslash. +set "SRC=%~dp0" +if "%SRC:~-1%"=="\" set "SRC=%SRC:~0,-1%" + +REM DEPLOY = sibling folder next to the project, NOT inside it. +for %%I in ("%SRC%\..") do set "PARENT=%%~fI" +set "DEPLOY=%PARENT%\newbury-exhibit_deploy" + +set HTTPS_REMOTE=https://%GITEA_HOST%/%OWNER%/%REPO%.git +set LAN_REMOTE=%LAN_URL%/%OWNER%/%REPO%.git echo. echo === Newbury Exhibit deploy === -echo Source: %SRC% -echo Target: https://%GITEA_HOST%/%OWNER%/%REPO% (branch %BRANCH%) +echo Source: %SRC% +echo Checkout: %DEPLOY% (sibling - outside the project) +echo Target: %HTTPS_REMOTE% (branch %BRANCH%) echo. -REM --- fresh _deploy checkout --- -if exist "%SRC%_deploy" ( - echo Removing old _deploy checkout... - rmdir /s /q "%SRC%_deploy" -) -echo Cloning %BRANCH%... -git clone -b %BRANCH% "https://%GITEA_HOST%/%OWNER%/%REPO%.git" "%SRC%_deploy" -if errorlevel 1 ( - echo ERROR: clone failed. Check credentials / branch exists. - pause & exit /b 1 +REM --- ensure ghost roster is present (copy from NewburyNights if missing) --- +if not exist "%SRC%\data\ghosts.json" ( + if exist "%PARENT%\NewburyNights\ghosts.enriched.json" ( + echo Copying ghost roster into data\ghosts.json ... + copy /y "%PARENT%\NewburyNights\ghosts.enriched.json" "%SRC%\data\ghosts.json" >nul + ) ) -REM --- auto-detect git identity from existing global config --- +REM --- fresh _deploy checkout (clone via HTTPS; fall back to LAN) --- +if exist "%DEPLOY%" ( + echo Removing old checkout... + rmdir /s /q "%DEPLOY%" +) +echo Cloning %BRANCH% via HTTPS... +git clone -b %BRANCH% "%HTTPS_REMOTE%" "%DEPLOY%" +if errorlevel 1 ( + echo HTTPS clone failed, trying direct LAN... + git clone -b %BRANCH% "%LAN_REMOTE%" "%DEPLOY%" + if errorlevel 1 ( echo ERROR: clone failed on both remotes. & pause & exit /b 1 ) +) + +REM --- git identity from global config --- for /f "tokens=*" %%a in ('git config --global user.name') do set GITNAME=%%a for /f "tokens=*" %%a in ('git config --global user.email') do set GITEMAIL=%%a if "!GITNAME!"=="" set GITNAME=jessikitty if "!GITEMAIL!"=="" set GITEMAIL=jess.rogerson.29@outlook.com -REM --- copy project files into checkout (exclude deploy/node_modules/git) --- +REM --- copy project into checkout. Source has no _deploy inside it now, --- +REM --- but still exclude the usual noise. .git in DEPLOY is protected. --- echo Syncing files... -robocopy "%SRC%." "%SRC%_deploy" /E /XD "_deploy" "node_modules" ".git" /XF "*.log" >nul +robocopy "%SRC%" "%DEPLOY%" /MIR /XD "node_modules" ".git" "newbury-exhibit_deploy" "backups" /XF "*.log" ".run.pid" ".deploy-installed" >nul -pushd "%SRC%_deploy" +pushd "%DEPLOY%" git config user.name "!GITNAME!" git config user.email "!GITEMAIL!" +git config http.postBuffer 524288000 +git config http.version HTTP/1.1 echo. echo === Staged changes === @@ -56,11 +81,21 @@ if "!MSG!"=="" set MSG=Update exhibit git commit -m "!MSG!" if errorlevel 1 ( echo Nothing to commit, or commit failed. -) else ( - git push origin %BRANCH% - if errorlevel 1 ( echo ERROR: push failed. & popd & pause & exit /b 1 ) - echo. - echo === Pushed to %BRANCH% successfully === + popd & pause & exit /b 0 ) + +echo. +echo Pushing via HTTPS... +git push origin %BRANCH% +if errorlevel 1 ( + echo HTTPS push failed ^(proxy/500?^). Retrying direct LAN... + git push "%LAN_REMOTE%" %BRANCH% + if errorlevel 1 ( + echo ERROR: push failed on both remotes. Commit saved in %DEPLOY%. + popd & pause & exit /b 1 + ) +) +echo. +echo === Pushed to %BRANCH% successfully === popd pause