diff --git a/docs/map-dashboard-feature-spec.md b/docs/map-dashboard-feature-spec.md index 1b5db65..b68c275 100644 --- a/docs/map-dashboard-feature-spec.md +++ b/docs/map-dashboard-feature-spec.md @@ -441,16 +441,302 @@ Cons: Allow arrow/WASD movement across the map and block movement with collision geometry. -This needs one of: +This should be treated as a map-data pipeline, not as one-off movement logic in +React. The runtime should load a stable map manifest generated from a Tiled +source file. The manifest should describe the map dimensions, background, +spawn point, points of interest, walkable polygons, and blocked polygons in one +shared coordinate system. + +The implemented repeatable map pipeline keeps the raw Tiled export and +normalized runtime manifest at: + +```txt +public/assets/map/maps/adventure/adventure.tiled.json +public/assets/map/maps/adventure/map.json +``` + +It uses the correct `80 x 45` map size with `24px` tiles, which gives a +`1920 x 1080` source-pixel coordinate space matching the background image. + +Supported authoring inputs: - a hand-authored collision mask image where walkable pixels are one color - a manual polygon/navmesh layer stored as JSON - a tile map exported from a map editor +Preferred authoring input: + +```txt +Tiled object layers exported as JSON +``` + +Use Tiled as the editable source of truth and convert it into an app-owned JSON +manifest before runtime use. Do not make the React client depend directly on +Tiled's full export shape. + +Recommended source/export layout: + +```txt +public/assets/map/maps/adventure/adventure.tiled.tmj +public/assets/map/maps/adventure/adventure.tiled.json +public/assets/map/maps/adventure/map.json +public/assets/map/maps/adventure/debug-preview.png +``` + +`adventure.tiled.tmj` is the editable Tiled source. `adventure.tiled.json` is +the raw export. `map.json` is the normalized runtime manifest. The debug preview +is optional but useful in PR review because collision geometry is otherwise hard +to inspect. + +Recommended Tiled layers: + +```txt +background +spawn +pointsOfInterest +walkable +blocked +``` + +Layer contracts: + +- `background`: image layer for `adventure-map-background.webp`. +- `spawn`: one or more point objects. The first implementation should require a + `default` spawn. +- `pointsOfInterest`: point or ellipse objects with stable app metadata. +- `walkable`: polygon objects defining where character feet may move. +- `blocked`: polygon objects that subtract obstacles from walkable areas. + +Recommended Tiled custom properties: + +Map-level properties: + +```txt +schema: portal-map-v1 +schemaVersion: 1 +mapId: adventure +coordinateSpace: source-pixels +sourceWidth: 1920 +sourceHeight: 1080 +backgroundPath: /assets/map/backgrounds/adventure-map-background.webp +movementKind: navmesh +poiInteraction: proximity +``` + +Spawn object properties: + +```txt +spawnId: default +facing: down +characterFootRadius: 14 +``` + +Point-of-interest object properties: + +```txt +locationId: slop-swamp | lava-castle | forest-knowledge | village + | guild-castle | whispers-hut | lunker-lake +label: user-facing location label +kind: posts | modules | wiki | events | static | feedback | daily-engagement +dialogKey: stable dialog renderer key +href: optional deeper route +nodeId: optional path/node compatibility anchor +region: short accessible region description +menuOrder: integer sort order +triggerShape: circle | ellipse +triggerRadius: proximity distance in source pixels +markerRadius: pointer/touch marker radius in source pixels +enabled: boolean +opensDialog: boolean +actionLabel: button or prompt label +``` + +Walkable polygon properties: + +```txt +navmeshId: stable polygon ID +collisionKind: walkable +blocksMovement: false +movementCost: 1 +debugColor: #2ecc71 +``` + +Blocked polygon properties: + +```txt +obstacleId: stable obstacle ID +collisionKind: blocked +blocksMovement: true +collisionPriority: 100 +debugColor: #e74c3c +``` + +Normalized runtime manifest shape: + +```json +{ + "schema": "portal-map-v1", + "schemaVersion": 1, + "id": "adventure", + "size": { "w": 1920, "h": 1080 }, + "background": "/assets/map/backgrounds/adventure-map-background.webp", + "spawn": { + "id": "default", + "x": 786, + "y": 660, + "facing": "down", + "characterFootRadius": 14 + }, + "movement": { + "kind": "navmesh", + "walkable": [ + { + "id": "core", + "points": [[6.67, 81.33], [56, 84], [84, 102.67]] + } + ], + "blocked": [ + { + "id": "lake", + "points": [[88, 388], [152, 364], [189.33, 325.33]] + } + ] + }, + "pointsOfInterest": [ + { + "id": "slop-swamp", + "label": "Slop Swamp", + "kind": "posts", + "x": 334, + "y": 884, + "triggerShape": "ellipse", + "triggerRadius": 120, + "markerRadius": 52, + "dialogKey": "slop-swamp", + "href": "/posts", + "enabled": true + } + ] +} +``` + +The converter should translate Tiled polygon points from local object +coordinates into absolute source-pixel coordinates by adding each object's +`x`/`y` to each polygon point. The runtime should not need to know that Tiled +stores polygon vertices relative to their object origin. + +The converter should translate POI geometry into a single interaction position: + +- Point object: use the object's `x`/`y` directly. +- Ellipse object: use the center point, `x + width / 2` and `y + height / 2`. +- Rectangle object, if used later: use the center point unless the object has + explicit `markerX`/`markerY` custom properties. +- Preserve the original object bounds as optional `triggerBounds` when the UI + needs ellipse or rectangle proximity instead of circular proximity. + +Movement model: + +- Keep the character position as a source-pixel coordinate. +- Use the character's feet point as the primary collision coordinate. +- Treat `characterFootRadius` as a small circle for edge testing so movement + does not feel like a single-pixel needle. +- On every animation frame, build a desired movement vector from keyboard, + pointer, or virtual joystick input. +- Normalize diagonal movement so diagonal speed is not faster. +- Test the desired next position against the navmesh. +- Permit movement only when the feet circle remains inside at least one + walkable polygon and outside all blocked polygons. +- If full movement is blocked, test the horizontal and vertical components + separately to allow natural sliding along walls. +- Clamp final position to the `1920 x 1080` source bounds. +- Convert the source-pixel position to CSS percentages for DOM rendering. + +Input model: + +- Desktop: support arrow keys and WASD. +- Pointer: allow click-to-walk or hold-to-move only if it does not conflict with + marker activation. Start with keyboard plus destination menu if pointer + semantics are unclear. +- Mobile: add a small virtual D-pad or joystick outside the map stage instead of + requiring precise drag movement over the art. +- Reduced motion: still allow direct position changes, but skip any decorative + movement effects. + +Point-of-interest proximity model: + +- On each movement update, compute distance from the character feet position to + each enabled point of interest. +- If the character enters a POI's trigger area, mark it as nearby. +- Show a compact interaction prompt for the nearest nearby POI. +- Pressing Enter, Space, or the prompt button opens the mapped dialog. +- Avoid automatically opening dialogs on proximity alone; auto-open can feel + jumpy when players are only passing through. +- Keep the destination menu as the accessible non-spatial way to reach the same + dialogs. + +Collision implementation options: + +- Initial implementation can use a simple point-in-polygon helper plus segment + distance checks for the feet radius. This keeps dependencies light. +- If polygon logic becomes complex, use a small geometry helper library, but do + not introduce a game engine solely for collision. +- Do not use tile collision unless future maps are authored as tilemaps from the + start. The current painted raster fits polygon collision better. + +Validation requirements: + +- `map.json` must match the expected schema and version. +- Source dimensions must match `1920 x 1080`. +- Background path must exist. +- Exactly one default spawn must exist. +- Spawn must be inside a walkable polygon and outside blocked polygons. +- Every enabled POI must have a stable `locationId`, `label`, `kind`, + `dialogKey`, `triggerRadius`, and `markerRadius`. +- Every enabled POI must be reachable from walkable space or explicitly marked + as visual-only/disabled. +- Walkable and blocked objects must be polygons with at least three points. +- POI IDs must align with the map dialog IDs. + +Implementation phases for free walk: + +1. Preserve the existing path/node map as the stable shipped version. +2. Add the Tiled source/export files under `public/assets/map/maps/adventure/`. +3. Add a converter script that creates `map.json` from the Tiled export. +4. Add schema validation for the normalized manifest. +5. Add a debug renderer that can overlay walkable, blocked, spawn, and POI + geometry in development. +6. Add a `useFreeWalkMovement` hook beside the existing path movement hook. +7. Gate free walk behind a config flag until collision QA is complete. +8. Wire POI proximity prompts to the existing location dialog system. +9. Add keyboard, destination-menu, and mobile movement coverage. +10. Run full e2e and headed/manual visual verification before replacing path + movement. + +Implemented pipeline/runtime files: + +```txt +src/app/(frontend)/dashboard/map/mapManifest.ts +src/app/(frontend)/dashboard/map/mapGeometry.ts +src/app/(frontend)/dashboard/map/useFreeWalkMovement.ts +src/app/(frontend)/dashboard/map/MapInteractionPrompt.tsx +src/app/(frontend)/dashboard/map/MapDebugOverlay.tsx +scripts/convert-tiled-map.mjs +scripts/validate-map-manifest.mjs +``` + +Regenerate and validate the runtime manifest with: + +```txt +corepack pnpm map:convert +``` + Pros: - strongest game feel - allows wandering and discovery +- keeps future maps repeatable if Tiled metadata is treated as the source +- lets the same map data drive collision, markers, proximity prompts, and + destination menus Cons: @@ -458,9 +744,12 @@ Cons: - hard to tune against raster art - easy to create frustrating invisible walls - more testing required on mobile and zoomed viewports +- needs a converter and validation layer so editor output does not leak into + runtime code -Decision: build path/node movement first. Add free movement only after the art -pipeline includes a collision mask or navmesh. +Decision: free movement is implemented as the active map movement model. It is +integrated with the Tiled pipeline, validated `map.json` output, proximity POI +prompts, keyboard/mobile movement controls, and the `?mapDebug=1` debug overlay. ## Client Architecture diff --git a/package.json b/package.json index cf8fbfa..78ed702 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "payload-3-boilerplate", "version": "1.0.0", + "packageManager": "pnpm@10.18.3", "description": "Payload V3 Website builder boilerplate for Railway", "license": "MIT", "type": "module", @@ -21,6 +22,7 @@ "ii": "cross-env NODE_OPTIONS=--no-deprecation pnpm --ignore-workspace install", "lint": "cross-env NODE_OPTIONS=--no-deprecation next lint", "lint:fix": "cross-env NODE_OPTIONS=--no-deprecation next lint --fix", + "map:convert": "node scripts/convert-tiled-map.mjs && node scripts/validate-map-manifest.mjs", "payload": "cross-env NODE_OPTIONS=--no-deprecation payload", "reinstall": "cross-env NODE_OPTIONS=--no-deprecation rm -rf node_modules && rm pnpm-lock.yaml && pnpm --ignore-workspace install", "start": "cross-env NODE_OPTIONS=--no-deprecation next start", diff --git a/public/assets/map/maps/adventure/adventure.tiled.json b/public/assets/map/maps/adventure/adventure.tiled.json new file mode 100644 index 0000000..6d3a80c --- /dev/null +++ b/public/assets/map/maps/adventure/adventure.tiled.json @@ -0,0 +1,2693 @@ +{ + "compressionlevel": -1, + "height": 45, + "infinite": false, + "layers": [ + { + "data": [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0 + ], + "height": 45, + "id": 1, + "name": "Tile Layer 1", + "opacity": 1, + "type": "tilelayer", + "visible": true, + "width": 80, + "x": 0, + "y": 0 + }, + { + "id": 3, + "image": "portal/public/assets/map/backgrounds/adventure-map-background.webp", + "imageheight": 1080, + "imagewidth": 1920, + "locked": true, + "name": "background", + "opacity": 1, + "type": "imagelayer", + "visible": true, + "x": 0, + "y": 0, + "properties": [ + { + "name": "portalLayerKind", + "type": "string", + "value": "background" + } + ] + }, + { + "draworder": "topdown", + "id": 4, + "name": "spawn", + "objects": [ + { + "height": 0, + "id": 1, + "name": "spawn", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "spawn", + "visible": true, + "width": 0, + "x": 786, + "y": 660, + "properties": [ + { + "name": "spawnId", + "type": "string", + "value": "default" + }, + { + "name": "facing", + "type": "string", + "value": "down" + }, + { + "name": "characterFootRadius", + "type": "int", + "value": 14 + } + ] + } + ], + "opacity": 1, + "type": "objectgroup", + "visible": true, + "x": 0, + "y": 0, + "properties": [ + { + "name": "portalLayerKind", + "type": "string", + "value": "spawn" + } + ] + }, + { + "draworder": "topdown", + "id": 6, + "name": "walkable", + "objects": [ + { + "height": 0, + "id": 12, + "name": "core", + "opacity": 1, + "polygon": [ + { + "x": 0, + "y": 0 + }, + { + "x": 49.3333333333333, + "y": 2.66666666666667 + }, + { + "x": 77.3333333333333, + "y": 21.3333333333333 + }, + { + "x": 136, + "y": 22.6666666666667 + }, + { + "x": 142.666666666667, + "y": -16 + }, + { + "x": 189.333333333333, + "y": 8 + }, + { + "x": 217.333333333333, + "y": 38.6666666666667 + }, + { + "x": 217.333333333333, + "y": 65.3333333333333 + }, + { + "x": 201.333333333333, + "y": 61.3333333333333 + }, + { + "x": 157.333333333333, + "y": 44 + }, + { + "x": 146.666666666667, + "y": 69.3333333333333 + }, + { + "x": 154.666666666667, + "y": 90.6666666666667 + }, + { + "x": 180, + "y": 109.333333333333 + }, + { + "x": 230.666666666667, + "y": 118.666666666667 + }, + { + "x": 273.333333333333, + "y": 134.666666666667 + }, + { + "x": 282.666666666667, + "y": 176 + }, + { + "x": 360, + "y": 197.333333333333 + }, + { + "x": 412, + "y": 213.333333333333 + }, + { + "x": 442.666666666667, + "y": 246.666666666667 + }, + { + "x": 456, + "y": 298.666666666667 + }, + { + "x": 494.666666666667, + "y": 333.333333333333 + }, + { + "x": 530.666666666667, + "y": 322.666666666667 + }, + { + "x": 541.333333333333, + "y": 301.333333333333 + }, + { + "x": 549.333333333333, + "y": 270.666666666667 + }, + { + "x": 536, + "y": 242.666666666667 + }, + { + "x": 528, + "y": 210.666666666667 + }, + { + "x": 572, + "y": 202.666666666667 + }, + { + "x": 590.666666666667, + "y": 226.666666666667 + }, + { + "x": 600, + "y": 262.666666666667 + }, + { + "x": 582.666666666667, + "y": 293.333333333333 + }, + { + "x": 597.333333333333, + "y": 329.333333333333 + }, + { + "x": 625.333333333333, + "y": 314.666666666667 + }, + { + "x": 653.333333333333, + "y": 292 + }, + { + "x": 684, + "y": 253.333333333333 + }, + { + "x": 665.333333333333, + "y": 208 + }, + { + "x": 690.666666666667, + "y": 177.333333333333 + }, + { + "x": 712, + "y": 169.333333333333 + }, + { + "x": 714.666666666667, + "y": 130.666666666667 + }, + { + "x": 717.333333333333, + "y": 97.3333333333333 + }, + { + "x": 722.666666666667, + "y": 73.3333333333333 + }, + { + "x": 757.333333333333, + "y": 70.6666666666667 + }, + { + "x": 773.333333333333, + "y": 104 + }, + { + "x": 801.333333333333, + "y": 125.333333333333 + }, + { + "x": 744, + "y": 146.666666666667 + }, + { + "x": 753.333333333333, + "y": 166.666666666667 + }, + { + "x": 730.666666666667, + "y": 210.666666666667 + }, + { + "x": 720, + "y": 256 + }, + { + "x": 722.666666666667, + "y": 282.666666666667 + }, + { + "x": 686.666666666667, + "y": 314.666666666667 + }, + { + "x": 652, + "y": 342.666666666667 + }, + { + "x": 653.333333333333, + "y": 372 + }, + { + "x": 666.666666666667, + "y": 426.666666666667 + }, + { + "x": 692, + "y": 449.333333333333 + }, + { + "x": 752, + "y": 462.666666666667 + }, + { + "x": 748, + "y": 500 + }, + { + "x": 773.333333333333, + "y": 536 + }, + { + "x": 813.333333333333, + "y": 550.666666666667 + }, + { + "x": 868, + "y": 540 + }, + { + "x": 921.333333333333, + "y": 513.333333333333 + }, + { + "x": 958.666666666667, + "y": 505.333333333333 + }, + { + "x": 974.666666666667, + "y": 448 + }, + { + "x": 996, + "y": 437.333333333333 + }, + { + "x": 1036, + "y": 450.666666666667 + }, + { + "x": 1033.33333333333, + "y": 500 + }, + { + "x": 1004, + "y": 546.666666666667 + }, + { + "x": 1029.33333333333, + "y": 569.333333333333 + }, + { + "x": 1086.66666666667, + "y": 577.333333333333 + }, + { + "x": 1144, + "y": 588 + }, + { + "x": 1194.66666666667, + "y": 614.666666666667 + }, + { + "x": 1228, + "y": 632 + }, + { + "x": 1272, + "y": 626.666666666667 + }, + { + "x": 1300, + "y": 638.666666666667 + }, + { + "x": 1362.66666666667, + "y": 654.666666666667 + }, + { + "x": 1380, + "y": 664 + }, + { + "x": 1378.66666666667, + "y": 692 + }, + { + "x": 1344, + "y": 689.333333333333 + }, + { + "x": 1304, + "y": 672 + }, + { + "x": 1244, + "y": 669.333333333333 + }, + { + "x": 1201.33333333333, + "y": 676 + }, + { + "x": 1160, + "y": 652 + }, + { + "x": 1108, + "y": 616 + }, + { + "x": 1049.33333333333, + "y": 616 + }, + { + "x": 994.666666666667, + "y": 602.666666666667 + }, + { + "x": 969.333333333333, + "y": 558.666666666667 + }, + { + "x": 928, + "y": 558.666666666667 + }, + { + "x": 890.666666666667, + "y": 562.666666666667 + }, + { + "x": 865.333333333333, + "y": 586.666666666667 + }, + { + "x": 812, + "y": 600 + }, + { + "x": 773.333333333333, + "y": 622.666666666667 + }, + { + "x": 765.333333333333, + "y": 653.333333333333 + }, + { + "x": 733.333333333333, + "y": 706.666666666667 + }, + { + "x": 692, + "y": 720 + }, + { + "x": 664, + "y": 750.666666666667 + }, + { + "x": 642.666666666667, + "y": 776 + }, + { + "x": 669.333333333333, + "y": 817.333333333333 + }, + { + "x": 690.666666666667, + "y": 842.666666666667 + }, + { + "x": 726.666666666667, + "y": 878.666666666667 + }, + { + "x": 778.666666666667, + "y": 912 + }, + { + "x": 814.666666666667, + "y": 926.666666666667 + }, + { + "x": 860, + "y": 949.333333333333 + }, + { + "x": 906.666666666667, + "y": 952 + }, + { + "x": 940, + "y": 948 + }, + { + "x": 945.333333333333, + "y": 909.333333333333 + }, + { + "x": 997.333333333333, + "y": 909.333333333333 + }, + { + "x": 997.333333333333, + "y": 941.333333333333 + }, + { + "x": 997.333333333333, + "y": 953.333333333333 + }, + { + "x": 1008, + "y": 994.666666666667 + }, + { + "x": 941.333333333333, + "y": 1000 + }, + { + "x": 852, + "y": 994.666666666667 + }, + { + "x": 796, + "y": 994.666666666667 + }, + { + "x": 776, + "y": 968 + }, + { + "x": 756, + "y": 937.333333333333 + }, + { + "x": 717.333333333333, + "y": 924 + }, + { + "x": 681.333333333333, + "y": 886.666666666667 + }, + { + "x": 657.333333333333, + "y": 874.666666666667 + }, + { + "x": 630.666666666667, + "y": 846.666666666667 + }, + { + "x": 600, + "y": 805.333333333333 + }, + { + "x": 581.333333333333, + "y": 790.666666666667 + }, + { + "x": 556, + "y": 790.666666666667 + }, + { + "x": 524, + "y": 797.333333333333 + }, + { + "x": 485.333333333333, + "y": 794.666666666667 + }, + { + "x": 444, + "y": 781.333333333333 + }, + { + "x": 425.333333333333, + "y": 801.333333333333 + }, + { + "x": 430.666666666667, + "y": 844 + }, + { + "x": 389.333333333333, + "y": 862.666666666667 + }, + { + "x": 402.666666666667, + "y": 893.333333333333 + }, + { + "x": 372, + "y": 885.333333333333 + }, + { + "x": 361.333333333333, + "y": 870.666666666667 + }, + { + "x": 366.666666666667, + "y": 860 + }, + { + "x": 369.333333333333, + "y": 848 + }, + { + "x": 398.666666666667, + "y": 812 + }, + { + "x": 380, + "y": 765.333333333333 + }, + { + "x": 356, + "y": 737.333333333333 + }, + { + "x": 341.333333333333, + "y": 696 + }, + { + "x": 313.333333333333, + "y": 682.666666666667 + }, + { + "x": 269.333333333333, + "y": 709.333333333333 + }, + { + "x": 254.666666666667, + "y": 752 + }, + { + "x": 250.666666666667, + "y": 794.666666666667 + }, + { + "x": 220, + "y": 809.333333333333 + }, + { + "x": 201.333333333333, + "y": 844 + }, + { + "x": 181.333333333333, + "y": 865.333333333333 + }, + { + "x": 173.333333333333, + "y": 912 + }, + { + "x": 166.666666666667, + "y": 926.666666666667 + }, + { + "x": 117.333333333333, + "y": 937.333333333333 + }, + { + "x": 109.333333333333, + "y": 889.333333333333 + }, + { + "x": 93.3333333333333, + "y": 845.333333333333 + }, + { + "x": 77.3333333333333, + "y": 825.333333333333 + }, + { + "x": 98.6666666666667, + "y": 754.666666666667 + }, + { + "x": 162.666666666667, + "y": 758.666666666667 + }, + { + "x": 192, + "y": 778.666666666667 + }, + { + "x": 229.333333333333, + "y": 772 + }, + { + "x": 197.333333333333, + "y": 732 + }, + { + "x": 225.333333333333, + "y": 709.333333333333 + }, + { + "x": 237.333333333333, + "y": 694.666666666667 + }, + { + "x": 282.666666666667, + "y": 677.333333333333 + }, + { + "x": 301.333333333333, + "y": 664 + }, + { + "x": 282.666666666667, + "y": 633.333333333333 + }, + { + "x": 257.333333333333, + "y": 613.333333333333 + }, + { + "x": 252, + "y": 582.666666666667 + }, + { + "x": 266.666666666667, + "y": 552 + }, + { + "x": 198.666666666667, + "y": 548 + }, + { + "x": 152, + "y": 528 + }, + { + "x": 146.666666666667, + "y": 505.333333333333 + }, + { + "x": 109.333333333333, + "y": 472 + }, + { + "x": 70.6666666666667, + "y": 481.333333333333 + }, + { + "x": 50.6666666666667, + "y": 466.666666666667 + }, + { + "x": 34.6666666666667, + "y": 428 + }, + { + "x": 30.6666666666667, + "y": 408 + }, + { + "x": 37.3333333333333, + "y": 349.333333333333 + }, + { + "x": 12, + "y": 294.666666666667 + }, + { + "x": 42.6666666666667, + "y": 241.333333333333 + }, + { + "x": 42.6666666666667, + "y": 200 + }, + { + "x": 42.6666666666667, + "y": 149.333333333333 + }, + { + "x": 18.6666666666667, + "y": 118.666666666667 + }, + { + "x": -5.33333333333333, + "y": 42.6666666666667 + } + ], + "rotation": 0, + "type": "walkable", + "visible": true, + "width": 0, + "x": 6.66666666666667, + "y": 81.3333333333333, + "properties": [ + { + "name": "navmeshId", + "type": "string", + "value": "core" + }, + { + "name": "collisionKind", + "type": "string", + "value": "walkable" + }, + { + "name": "blocksMovement", + "type": "bool", + "value": false + }, + { + "name": "movementCost", + "type": "int", + "value": 1 + }, + { + "name": "debugColor", + "type": "string", + "value": "#2ecc71" + } + ] + }, + { + "height": 0, + "id": 18, + "name": "town", + "opacity": 1, + "polygon": [ + { + "x": 0, + "y": 0 + }, + { + "x": -13.3333333333334, + "y": -40 + }, + { + "x": -33.3333333333334, + "y": -58.6666666666667 + }, + { + "x": -94.6666666666667, + "y": -106.666666666667 + }, + { + "x": -114.666666666667, + "y": -108 + }, + { + "x": -101.333333333333, + "y": -152 + }, + { + "x": -98.6666666666667, + "y": -154.666666666667 + }, + { + "x": -69.3333333333334, + "y": -160 + }, + { + "x": -48, + "y": -181.333333333333 + }, + { + "x": -17.3333333333334, + "y": -184 + }, + { + "x": -18.6666666666667, + "y": -150.666666666667 + }, + { + "x": 40, + "y": -148 + }, + { + "x": 70.6666666666665, + "y": -165.333333333333 + }, + { + "x": 76, + "y": -196 + }, + { + "x": 122.666666666667, + "y": -180 + }, + { + "x": 130.666666666667, + "y": -146.666666666667 + }, + { + "x": 92, + "y": -120 + }, + { + "x": 78.6666666666665, + "y": -85.3333333333334 + }, + { + "x": 52, + "y": -62.6666666666667 + }, + { + "x": 44, + "y": -17.3333333333334 + }, + { + "x": 32, + "y": 26.6666666666666 + }, + { + "x": -8, + "y": 25.3333333333333 + }, + { + "x": -8, + "y": -10.6666666666667 + } + ], + "rotation": 0, + "type": "walkable", + "visible": true, + "width": 0, + "x": 968, + "y": 964, + "properties": [ + { + "name": "navmeshId", + "type": "string", + "value": "town" + }, + { + "name": "collisionKind", + "type": "string", + "value": "walkable" + }, + { + "name": "blocksMovement", + "type": "bool", + "value": false + }, + { + "name": "movementCost", + "type": "int", + "value": 1 + }, + { + "name": "debugColor", + "type": "string", + "value": "#2ecc71" + } + ] + }, + { + "height": 0, + "id": 19, + "name": "lavaRoad", + "opacity": 1, + "polygon": [ + { + "x": 0, + "y": 0 + }, + { + "x": 50.6666666666667, + "y": 10.6666666666666 + }, + { + "x": 94.6666666666667, + "y": 45.3333333333334 + }, + { + "x": 145.333333333333, + "y": 29.3333333333334 + }, + { + "x": 212, + "y": -8 + }, + { + "x": 250.666666666667, + "y": -34.6666666666666 + }, + { + "x": 309.333333333333, + "y": -74.6666666666666 + }, + { + "x": 322.666666666667, + "y": -116 + }, + { + "x": 368, + "y": -140 + }, + { + "x": 348, + "y": -182.666666666667 + }, + { + "x": 314.666666666667, + "y": -240 + }, + { + "x": 326.666666666667, + "y": -296 + }, + { + "x": 332, + "y": -296 + }, + { + "x": 373.333333333333, + "y": -298.666666666667 + }, + { + "x": 430.666666666667, + "y": -262.666666666667 + }, + { + "x": 478.666666666667, + "y": -228 + }, + { + "x": 441.333333333333, + "y": -182.666666666667 + }, + { + "x": 418.666666666667, + "y": -158.666666666667 + }, + { + "x": 432, + "y": -126.666666666667 + }, + { + "x": 373.333333333333, + "y": -89.3333333333334 + }, + { + "x": 346.666666666667, + "y": -42.6666666666666 + }, + { + "x": 265.333333333333, + "y": 8 + }, + { + "x": 229.333333333333, + "y": 34.6666666666666 + }, + { + "x": 162.666666666667, + "y": 69.3333333333334 + }, + { + "x": 125.333333333333, + "y": 101.333333333333 + }, + { + "x": 110.666666666667, + "y": 145.333333333333 + }, + { + "x": 50.6666666666667, + "y": 186.666666666667 + }, + { + "x": 4, + "y": 197.333333333333 + }, + { + "x": -42.6666666666667, + "y": 194.666666666667 + }, + { + "x": -106.666666666667, + "y": 205.333333333333 + }, + { + "x": -112, + "y": 161.333333333333 + }, + { + "x": -68, + "y": 158.666666666667 + }, + { + "x": 6.66666666666674, + "y": 140 + }, + { + "x": 52, + "y": 110.666666666667 + }, + { + "x": 64, + "y": 74.6666666666666 + }, + { + "x": 36, + "y": 58.6666666666666 + }, + { + "x": 0, + "y": 24 + } + ], + "rotation": 0, + "type": "walkable", + "visible": true, + "width": 0, + "x": 1385.33333333333, + "y": 746.666666666667, + "properties": [ + { + "name": "navmeshId", + "type": "string", + "value": "lavaRoad" + }, + { + "name": "collisionKind", + "type": "string", + "value": "walkable" + }, + { + "name": "blocksMovement", + "type": "bool", + "value": false + }, + { + "name": "movementCost", + "type": "int", + "value": 1 + }, + { + "name": "debugColor", + "type": "string", + "value": "#2ecc71" + } + ] + } + ], + "opacity": 1, + "type": "objectgroup", + "visible": true, + "x": 0, + "y": 0, + "properties": [ + { + "name": "portalLayerKind", + "type": "string", + "value": "walkable" + } + ] + }, + { + "draworder": "topdown", + "id": 7, + "name": "blocked", + "objects": [ + { + "height": 0, + "id": 13, + "name": "lake", + "opacity": 1, + "polygon": [ + { + "x": 0, + "y": 0 + }, + { + "x": 64, + "y": -24 + }, + { + "x": 101.333333333333, + "y": -62.6666666666667 + }, + { + "x": 148, + "y": -53.3333333333334 + }, + { + "x": 194.666666666667, + "y": -62.6666666666667 + }, + { + "x": 226.666666666667, + "y": -26.6666666666667 + }, + { + "x": 272, + "y": -1.33333333333337 + }, + { + "x": 241.333333333333, + "y": 22.6666666666666 + }, + { + "x": 240, + "y": 49.3333333333333 + }, + { + "x": 276, + "y": 65.3333333333333 + }, + { + "x": 252, + "y": 98.6666666666666 + }, + { + "x": 208, + "y": 121.333333333333 + }, + { + "x": 221.333333333333, + "y": 162.666666666667 + }, + { + "x": 244, + "y": 194.666666666667 + }, + { + "x": 221.333333333333, + "y": 209.333333333333 + }, + { + "x": 198.666666666667, + "y": 212 + }, + { + "x": 164, + "y": 184 + }, + { + "x": 164, + "y": 168 + }, + { + "x": 134.666666666667, + "y": 142.666666666667 + }, + { + "x": 113.333333333333, + "y": 129.333333333333 + }, + { + "x": 81.3333333333333, + "y": 117.333333333333 + }, + { + "x": 32, + "y": 106.666666666667 + }, + { + "x": 4, + "y": 88 + }, + { + "x": -1.33333333333334, + "y": 52 + }, + { + "x": -6.66666666666667, + "y": 18.6666666666666 + } + ], + "rotation": 0, + "type": "blocked", + "visible": true, + "width": 0, + "x": 88, + "y": 388, + "properties": [ + { + "name": "obstacleId", + "type": "string", + "value": "lake" + }, + { + "name": "collisionKind", + "type": "string", + "value": "blocked" + }, + { + "name": "blocksMovement", + "type": "bool", + "value": true + }, + { + "name": "collisionPriority", + "type": "int", + "value": 100 + }, + { + "name": "debugColor", + "type": "string", + "value": "#e74c3c" + } + ] + }, + { + "height": 0, + "id": 14, + "name": "tree1", + "opacity": 1, + "polygon": [ + { + "x": 0, + "y": 0 + }, + { + "x": 8, + "y": -18.6666666666667 + }, + { + "x": 10.6666666666667, + "y": -46.6666666666667 + }, + { + "x": -9.33333333333334, + "y": -60 + }, + { + "x": -36, + "y": -41.3333333333333 + }, + { + "x": -24, + "y": -12 + }, + { + "x": -16, + "y": 2.66666666666669 + } + ], + "rotation": 0, + "type": "blocked", + "visible": true, + "width": 0, + "x": 148, + "y": 269.333333333333, + "properties": [ + { + "name": "obstacleId", + "type": "string", + "value": "tree1" + }, + { + "name": "collisionKind", + "type": "string", + "value": "blocked" + }, + { + "name": "blocksMovement", + "type": "bool", + "value": true + }, + { + "name": "collisionPriority", + "type": "int", + "value": 100 + }, + { + "name": "debugColor", + "type": "string", + "value": "#e74c3c" + } + ] + }, + { + "height": 0, + "id": 15, + "name": "tree2", + "opacity": 1, + "polygon": [ + { + "x": 0, + "y": 0 + }, + { + "x": 18.6666666666667, + "y": -18.6666666666667 + }, + { + "x": 38.6666666666667, + "y": -5.33333333333334 + }, + { + "x": 46.6666666666667, + "y": 9.33333333333334 + }, + { + "x": 24, + "y": 30.6666666666667 + }, + { + "x": 5.33333333333334, + "y": 21.3333333333333 + } + ], + "rotation": 0, + "type": "blocked", + "visible": true, + "width": 0, + "x": 50.6666666666667, + "y": 158.666666666667, + "properties": [ + { + "name": "obstacleId", + "type": "string", + "value": "tree2" + }, + { + "name": "collisionKind", + "type": "string", + "value": "blocked" + }, + { + "name": "blocksMovement", + "type": "bool", + "value": true + }, + { + "name": "collisionPriority", + "type": "int", + "value": 100 + }, + { + "name": "debugColor", + "type": "string", + "value": "#e74c3c" + } + ] + }, + { + "height": 0, + "id": 16, + "name": "grove", + "opacity": 1, + "polygon": [ + { + "x": 0, + "y": 0 + }, + { + "x": 2.66666666666669, + "y": -44 + }, + { + "x": 8, + "y": -81.3333333333333 + }, + { + "x": 33.3333333333334, + "y": -109.333333333333 + }, + { + "x": 62.6666666666667, + "y": -140 + }, + { + "x": 97.3333333333334, + "y": -125.333333333333 + }, + { + "x": 165.333333333333, + "y": -130.666666666667 + }, + { + "x": 206.666666666667, + "y": -122.666666666667 + }, + { + "x": 242.666666666667, + "y": -109.333333333333 + }, + { + "x": 234.666666666667, + "y": -82.6666666666666 + }, + { + "x": 248, + "y": -28 + }, + { + "x": 286.666666666667, + "y": -18.6666666666666 + }, + { + "x": 330.666666666667, + "y": 8 + }, + { + "x": 344, + "y": 28 + }, + { + "x": 333.333333333333, + "y": 68 + }, + { + "x": 348, + "y": 74.6666666666666 + }, + { + "x": 320, + "y": 114.666666666667 + }, + { + "x": 282.666666666667, + "y": 118.666666666667 + }, + { + "x": 254.666666666667, + "y": 98.6666666666666 + }, + { + "x": 212, + "y": 88 + }, + { + "x": 154.666666666667, + "y": 81.3333333333334 + }, + { + "x": 137.333333333333, + "y": 54.6666666666666 + }, + { + "x": 76, + "y": 38.6666666666666 + }, + { + "x": 53.3333333333334, + "y": 18.6666666666666 + } + ], + "rotation": 0, + "type": "blocked", + "visible": true, + "width": 0, + "x": 374.666666666667, + "y": 570.666666666667, + "properties": [ + { + "name": "obstacleId", + "type": "string", + "value": "grove" + }, + { + "name": "collisionKind", + "type": "string", + "value": "blocked" + }, + { + "name": "blocksMovement", + "type": "bool", + "value": true + }, + { + "name": "collisionPriority", + "type": "int", + "value": 100 + }, + { + "name": "debugColor", + "type": "string", + "value": "#e74c3c" + } + ] + }, + { + "height": 0, + "id": 17, + "name": "swamp1", + "opacity": 1, + "polygon": [ + { + "x": 0, + "y": 0 + }, + { + "x": 20, + "y": -10.6666666666666 + }, + { + "x": 45.3333333333334, + "y": 0 + }, + { + "x": 62.6666666666667, + "y": 0 + }, + { + "x": 101.333333333333, + "y": 2.66666666666674 + }, + { + "x": 70.6666666666667, + "y": -28 + }, + { + "x": 114.666666666667, + "y": -4 + }, + { + "x": 140, + "y": 6.66666666666674 + }, + { + "x": 174.666666666667, + "y": 18.6666666666667 + }, + { + "x": 197.333333333333, + "y": 25.3333333333334 + }, + { + "x": 209.333333333333, + "y": 36 + }, + { + "x": 242.666666666667, + "y": 33.3333333333334 + }, + { + "x": 288, + "y": 49.3333333333334 + }, + { + "x": 332, + "y": 62.6666666666667 + }, + { + "x": 368, + "y": 84 + }, + { + "x": 392, + "y": 97.3333333333334 + }, + { + "x": 376, + "y": 122.666666666667 + }, + { + "x": 340, + "y": 138.666666666667 + }, + { + "x": 324, + "y": 172 + }, + { + "x": 304, + "y": 201.333333333333 + }, + { + "x": 273.333333333333, + "y": 206.666666666667 + }, + { + "x": 244, + "y": 210.666666666667 + }, + { + "x": 168, + "y": 210.666666666667 + }, + { + "x": 137.333333333333, + "y": 204 + }, + { + "x": 102.666666666667, + "y": 190.666666666667 + }, + { + "x": 89.3333333333334, + "y": 158.666666666667 + }, + { + "x": 113.333333333333, + "y": 156 + }, + { + "x": 120, + "y": 161.333333333333 + }, + { + "x": 134.666666666667, + "y": 169.333333333333 + }, + { + "x": 149.333333333333, + "y": 168 + }, + { + "x": 186.666666666667, + "y": 134.666666666667 + }, + { + "x": 248, + "y": 137.333333333333 + }, + { + "x": 273.333333333333, + "y": 129.333333333333 + }, + { + "x": 284, + "y": 93.3333333333334 + }, + { + "x": 257.333333333333, + "y": 57.3333333333334 + }, + { + "x": 220, + "y": 60 + }, + { + "x": 190.666666666667, + "y": 93.3333333333334 + }, + { + "x": 170.666666666667, + "y": 110.666666666667 + }, + { + "x": 137.333333333333, + "y": 121.333333333333 + }, + { + "x": 97.3333333333334, + "y": 118.666666666667 + }, + { + "x": 74.6666666666667, + "y": 116 + }, + { + "x": 49.3333333333334, + "y": 108 + }, + { + "x": 17.3333333333334, + "y": 72 + }, + { + "x": -9.33333333333331, + "y": 48 + } + ], + "rotation": 0, + "type": "blocked", + "visible": true, + "width": 0, + "x": 302.666666666667, + "y": 637.333333333333, + "properties": [ + { + "name": "obstacleId", + "type": "string", + "value": "swamp1" + }, + { + "name": "collisionKind", + "type": "string", + "value": "blocked" + }, + { + "name": "blocksMovement", + "type": "bool", + "value": true + }, + { + "name": "collisionPriority", + "type": "int", + "value": 100 + }, + { + "name": "debugColor", + "type": "string", + "value": "#e74c3c" + } + ] + } + ], + "opacity": 1, + "type": "objectgroup", + "visible": true, + "x": 0, + "y": 0, + "properties": [ + { + "name": "portalLayerKind", + "type": "string", + "value": "blocked" + } + ] + }, + { + "draworder": "topdown", + "id": 5, + "name": "pointsOfInterest", + "objects": [ + { + "ellipse": true, + "height": 268, + "id": 2, + "name": "slop-swamp", + "opacity": 1, + "rotation": 0, + "type": "poi", + "visible": true, + "width": 488, + "x": 90, + "y": 750, + "properties": [ + { + "name": "locationId", + "type": "string", + "value": "slop-swamp" + }, + { + "name": "label", + "type": "string", + "value": "Slop Swamp" + }, + { + "name": "kind", + "type": "string", + "value": "posts" + }, + { + "name": "dialogKey", + "type": "string", + "value": "slop-swamp" + }, + { + "name": "href", + "type": "string", + "value": "/posts" + }, + { + "name": "nodeId", + "type": "string", + "value": "swamp" + }, + { + "name": "region", + "type": "string", + "value": "The morass of x not y." + }, + { + "name": "menuOrder", + "type": "int", + "value": 10 + }, + { + "name": "triggerShape", + "type": "string", + "value": "ellipse" + }, + { + "name": "triggerRadius", + "type": "int", + "value": 120 + }, + { + "name": "markerRadius", + "type": "int", + "value": 52 + }, + { + "name": "enabled", + "type": "bool", + "value": true + }, + { + "name": "opensDialog", + "type": "bool", + "value": true + }, + { + "name": "actionLabel", + "type": "string", + "value": "Read posts" + } + ] + }, + { + "ellipse": true, + "height": 146, + "id": 5, + "name": "village", + "opacity": 1, + "rotation": 0, + "type": "poi", + "visible": true, + "width": 206, + "x": 878, + "y": 780, + "properties": [ + { + "name": "locationId", + "type": "string", + "value": "village" + }, + { + "name": "label", + "type": "string", + "value": "The Village" + }, + { + "name": "kind", + "type": "string", + "value": "events" + }, + { + "name": "dialogKey", + "type": "string", + "value": "village" + }, + { + "name": "href", + "type": "string", + "value": "/events" + }, + { + "name": "nodeId", + "type": "string", + "value": "village" + }, + { + "name": "region", + "type": "string", + "value": "A hub of infinite meetings." + }, + { + "name": "menuOrder", + "type": "int", + "value": 40 + }, + { + "name": "triggerShape", + "type": "string", + "value": "ellipse" + }, + { + "name": "triggerRadius", + "type": "int", + "value": 96 + }, + { + "name": "markerRadius", + "type": "int", + "value": 48 + }, + { + "name": "enabled", + "type": "bool", + "value": true + }, + { + "name": "opensDialog", + "type": "bool", + "value": true + }, + { + "name": "actionLabel", + "type": "string", + "value": "View events" + } + ] + }, + { + "height": 0, + "id": 6, + "name": "lava-castle", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "poi", + "visible": true, + "width": 0, + "x": 1772, + "y": 522, + "properties": [ + { + "name": "locationId", + "type": "string", + "value": "lava-castle" + }, + { + "name": "label", + "type": "string", + "value": "Dungeon of Distant Modules" + }, + { + "name": "kind", + "type": "string", + "value": "modules" + }, + { + "name": "dialogKey", + "type": "string", + "value": "lava-castle" + }, + { + "name": "href", + "type": "string", + "value": "/modules" + }, + { + "name": "nodeId", + "type": "string", + "value": "lava-castle" + }, + { + "name": "region", + "type": "string", + "value": "Floor is lava!" + }, + { + "name": "menuOrder", + "type": "int", + "value": 20 + }, + { + "name": "triggerShape", + "type": "string", + "value": "circle" + }, + { + "name": "triggerRadius", + "type": "int", + "value": 96 + }, + { + "name": "markerRadius", + "type": "int", + "value": 44 + }, + { + "name": "enabled", + "type": "bool", + "value": true + }, + { + "name": "opensDialog", + "type": "bool", + "value": true + }, + { + "name": "actionLabel", + "type": "string", + "value": "View modules" + } + ] + }, + { + "height": 0, + "id": 7, + "name": "forest", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "poi", + "visible": true, + "width": 0, + "x": 748, + "y": 174, + "properties": [ + { + "name": "locationId", + "type": "string", + "value": "forest-knowledge" + }, + { + "name": "label", + "type": "string", + "value": "Forest of Unknown Knowledge" + }, + { + "name": "kind", + "type": "string", + "value": "wiki" + }, + { + "name": "dialogKey", + "type": "string", + "value": "forest-knowledge" + }, + { + "name": "href", + "type": "string", + "value": "/wiki" + }, + { + "name": "nodeId", + "type": "string", + "value": "forest" + }, + { + "name": "region", + "type": "string", + "value": "Wiki-willow of shadowy facts." + }, + { + "name": "menuOrder", + "type": "int", + "value": 30 + }, + { + "name": "triggerShape", + "type": "string", + "value": "circle" + }, + { + "name": "triggerRadius", + "type": "int", + "value": 88 + }, + { + "name": "markerRadius", + "type": "int", + "value": 42 + }, + { + "name": "enabled", + "type": "bool", + "value": true + }, + { + "name": "opensDialog", + "type": "bool", + "value": true + }, + { + "name": "actionLabel", + "type": "string", + "value": "Open wiki" + } + ] + }, + { + "height": 0, + "id": 8, + "name": "hut", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "poi", + "visible": true, + "width": 0, + "x": 562, + "y": 322, + "properties": [ + { + "name": "locationId", + "type": "string", + "value": "whispers-hut" + }, + { + "name": "label", + "type": "string", + "value": "Hut of Helpless Whispers" + }, + { + "name": "kind", + "type": "string", + "value": "feedback" + }, + { + "name": "dialogKey", + "type": "string", + "value": "whispers-hut" + }, + { + "name": "href", + "type": "string", + "value": "/feedback" + }, + { + "name": "nodeId", + "type": "string", + "value": "hut" + }, + { + "name": "region", + "type": "string", + "value": "Speak your mind to the uncaring winds." + }, + { + "name": "menuOrder", + "type": "int", + "value": 60 + }, + { + "name": "triggerShape", + "type": "string", + "value": "circle" + }, + { + "name": "triggerRadius", + "type": "int", + "value": 80 + }, + { + "name": "markerRadius", + "type": "int", + "value": 40 + }, + { + "name": "enabled", + "type": "bool", + "value": true + }, + { + "name": "opensDialog", + "type": "bool", + "value": true + }, + { + "name": "actionLabel", + "type": "string", + "value": "Give feedback" + } + ] + }, + { + "height": 0, + "id": 9, + "name": "lake", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "poi", + "visible": true, + "width": 0, + "x": 94, + "y": 340, + "properties": [ + { + "name": "locationId", + "type": "string", + "value": "lunker-lake" + }, + { + "name": "label", + "type": "string", + "value": "The Pond of Points" + }, + { + "name": "kind", + "type": "string", + "value": "daily-engagement" + }, + { + "name": "dialogKey", + "type": "string", + "value": "lunker-lake" + }, + { + "name": "href", + "type": "string", + "value": "" + }, + { + "name": "nodeId", + "type": "string", + "value": "lake" + }, + { + "name": "region", + "type": "string", + "value": "Cast a line into the lake each day to see what you catch." + }, + { + "name": "menuOrder", + "type": "int", + "value": 70 + }, + { + "name": "triggerShape", + "type": "string", + "value": "circle" + }, + { + "name": "triggerRadius", + "type": "int", + "value": 104 + }, + { + "name": "markerRadius", + "type": "int", + "value": 46 + }, + { + "name": "enabled", + "type": "bool", + "value": true + }, + { + "name": "opensDialog", + "type": "bool", + "value": true + }, + { + "name": "actionLabel", + "type": "string", + "value": "Check in" + } + ] + }, + { + "height": 0, + "id": 10, + "name": "member-gate", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "poi", + "visible": true, + "width": 0, + "x": 1018, + "y": 554, + "properties": [ + { + "name": "locationId", + "type": "string", + "value": "member-gate" + }, + { + "name": "label", + "type": "string", + "value": "Member Gate" + }, + { + "name": "kind", + "type": "string", + "value": "profile" + }, + { + "name": "dialogKey", + "type": "string", + "value": "member-gate" + }, + { + "name": "href", + "type": "string", + "value": "/me" + }, + { + "name": "nodeId", + "type": "string", + "value": "mine" + }, + { + "name": "region", + "type": "string", + "value": "Enter the mines. Does work and gems await?" + }, + { + "name": "menuOrder", + "type": "int", + "value": 80 + }, + { + "name": "triggerShape", + "type": "string", + "value": "circle" + }, + { + "name": "triggerRadius", + "type": "int", + "value": 72 + }, + { + "name": "markerRadius", + "type": "int", + "value": 36 + }, + { + "name": "enabled", + "type": "bool", + "value": false + }, + { + "name": "opensDialog", + "type": "bool", + "value": true + }, + { + "name": "actionLabel", + "type": "string", + "value": "Visit profile" + } + ] + }, + { + "height": 0, + "id": 11, + "name": "guild-castle", + "opacity": 1, + "point": true, + "rotation": 0, + "type": "poi", + "visible": true, + "width": 0, + "x": 1136, + "y": 258, + "properties": [ + { + "name": "locationId", + "type": "string", + "value": "guild-castle" + }, + { + "name": "label", + "type": "string", + "value": "Guild Castle" + }, + { + "name": "kind", + "type": "string", + "value": "static" + }, + { + "name": "dialogKey", + "type": "string", + "value": "guild-castle" + }, + { + "name": "href", + "type": "string", + "value": "" + }, + { + "name": "nodeId", + "type": "string", + "value": "mine" + }, + { + "name": "region", + "type": "string", + "value": "Queen Raida resides over the hall of mercenaries." + }, + { + "name": "menuOrder", + "type": "int", + "value": 50 + }, + { + "name": "triggerShape", + "type": "string", + "value": "circle" + }, + { + "name": "triggerRadius", + "type": "int", + "value": 96 + }, + { + "name": "markerRadius", + "type": "int", + "value": 44 + }, + { + "name": "enabled", + "type": "bool", + "value": true + }, + { + "name": "opensDialog", + "type": "bool", + "value": true + }, + { + "name": "actionLabel", + "type": "string", + "value": "Approach castle" + } + ] + } + ], + "opacity": 1, + "type": "objectgroup", + "visible": true, + "x": 0, + "y": 0, + "properties": [ + { + "name": "portalLayerKind", + "type": "string", + "value": "pointsOfInterest" + } + ] + } + ], + "nextlayerid": 8, + "nextobjectid": 20, + "orientation": "orthogonal", + "renderorder": "right-down", + "tiledversion": "1.12.2-2-geb83ddb9", + "tileheight": 24, + "tilesets": [], + "tilewidth": 24, + "type": "map", + "version": "1.10", + "width": 80, + "properties": [ + { + "name": "schema", + "type": "string", + "value": "portal-map-v1" + }, + { + "name": "schemaVersion", + "type": "int", + "value": 1 + }, + { + "name": "mapId", + "type": "string", + "value": "adventure" + }, + { + "name": "coordinateSpace", + "type": "string", + "value": "source-pixels" + }, + { + "name": "sourceWidth", + "type": "int", + "value": 1920 + }, + { + "name": "sourceHeight", + "type": "int", + "value": 1080 + }, + { + "name": "backgroundPath", + "type": "string", + "value": "/assets/map/backgrounds/adventure-map-background.webp" + }, + { + "name": "movementKind", + "type": "string", + "value": "navmesh" + }, + { + "name": "poiInteraction", + "type": "string", + "value": "proximity" + } + ] +} diff --git a/public/assets/map/maps/adventure/map.json b/public/assets/map/maps/adventure/map.json new file mode 100644 index 0000000..e493c62 --- /dev/null +++ b/public/assets/map/maps/adventure/map.json @@ -0,0 +1,1588 @@ +{ + "background": "/assets/map/backgrounds/adventure-map-background.webp", + "movement": { + "blocked": [ + { + "id": "lake", + "points": [ + [ + 88, + 388 + ], + [ + 152, + 364 + ], + [ + 189.33, + 325.33 + ], + [ + 236, + 334.67 + ], + [ + 282.67, + 325.33 + ], + [ + 314.67, + 361.33 + ], + [ + 360, + 386.67 + ], + [ + 329.33, + 410.67 + ], + [ + 328, + 437.33 + ], + [ + 364, + 453.33 + ], + [ + 340, + 486.67 + ], + [ + 296, + 509.33 + ], + [ + 309.33, + 550.67 + ], + [ + 332, + 582.67 + ], + [ + 309.33, + 597.33 + ], + [ + 286.67, + 600 + ], + [ + 252, + 572 + ], + [ + 252, + 556 + ], + [ + 222.67, + 530.67 + ], + [ + 201.33, + 517.33 + ], + [ + 169.33, + 505.33 + ], + [ + 120, + 494.67 + ], + [ + 92, + 476 + ], + [ + 86.67, + 440 + ], + [ + 81.33, + 406.67 + ] + ] + }, + { + "id": "tree1", + "points": [ + [ + 148, + 269.33 + ], + [ + 156, + 250.67 + ], + [ + 158.67, + 222.67 + ], + [ + 138.67, + 209.33 + ], + [ + 112, + 228 + ], + [ + 124, + 257.33 + ], + [ + 132, + 272 + ] + ] + }, + { + "id": "tree2", + "points": [ + [ + 50.67, + 158.67 + ], + [ + 69.33, + 140 + ], + [ + 89.33, + 153.33 + ], + [ + 97.33, + 168 + ], + [ + 74.67, + 189.33 + ], + [ + 56, + 180 + ] + ] + }, + { + "id": "grove", + "points": [ + [ + 374.67, + 570.67 + ], + [ + 377.33, + 526.67 + ], + [ + 382.67, + 489.33 + ], + [ + 408, + 461.33 + ], + [ + 437.33, + 430.67 + ], + [ + 472, + 445.33 + ], + [ + 540, + 440 + ], + [ + 581.33, + 448 + ], + [ + 617.33, + 461.33 + ], + [ + 609.33, + 488 + ], + [ + 622.67, + 542.67 + ], + [ + 661.33, + 552 + ], + [ + 705.33, + 578.67 + ], + [ + 718.67, + 598.67 + ], + [ + 708, + 638.67 + ], + [ + 722.67, + 645.33 + ], + [ + 694.67, + 685.33 + ], + [ + 657.33, + 689.33 + ], + [ + 629.33, + 669.33 + ], + [ + 586.67, + 658.67 + ], + [ + 529.33, + 652 + ], + [ + 512, + 625.33 + ], + [ + 450.67, + 609.33 + ], + [ + 428, + 589.33 + ] + ] + }, + { + "id": "swamp1", + "points": [ + [ + 302.67, + 637.33 + ], + [ + 322.67, + 626.67 + ], + [ + 348, + 637.33 + ], + [ + 365.33, + 637.33 + ], + [ + 404, + 640 + ], + [ + 373.33, + 609.33 + ], + [ + 417.33, + 633.33 + ], + [ + 442.67, + 644 + ], + [ + 477.33, + 656 + ], + [ + 500, + 662.67 + ], + [ + 512, + 673.33 + ], + [ + 545.33, + 670.67 + ], + [ + 590.67, + 686.67 + ], + [ + 634.67, + 700 + ], + [ + 670.67, + 721.33 + ], + [ + 694.67, + 734.67 + ], + [ + 678.67, + 760 + ], + [ + 642.67, + 776 + ], + [ + 626.67, + 809.33 + ], + [ + 606.67, + 838.67 + ], + [ + 576, + 844 + ], + [ + 546.67, + 848 + ], + [ + 470.67, + 848 + ], + [ + 440, + 841.33 + ], + [ + 405.33, + 828 + ], + [ + 392, + 796 + ], + [ + 416, + 793.33 + ], + [ + 422.67, + 798.67 + ], + [ + 437.33, + 806.67 + ], + [ + 452, + 805.33 + ], + [ + 489.33, + 772 + ], + [ + 550.67, + 774.67 + ], + [ + 576, + 766.67 + ], + [ + 586.67, + 730.67 + ], + [ + 560, + 694.67 + ], + [ + 522.67, + 697.33 + ], + [ + 493.33, + 730.67 + ], + [ + 473.33, + 748 + ], + [ + 440, + 758.67 + ], + [ + 400, + 756 + ], + [ + 377.33, + 753.33 + ], + [ + 352, + 745.33 + ], + [ + 320, + 709.33 + ], + [ + 293.33, + 685.33 + ] + ] + } + ], + "kind": "navmesh", + "walkable": [ + { + "id": "core", + "movementCost": 1, + "points": [ + [ + 6.67, + 81.33 + ], + [ + 56, + 84 + ], + [ + 84, + 102.67 + ], + [ + 142.67, + 104 + ], + [ + 149.33, + 65.33 + ], + [ + 196, + 89.33 + ], + [ + 224, + 120 + ], + [ + 224, + 146.67 + ], + [ + 208, + 142.67 + ], + [ + 164, + 125.33 + ], + [ + 153.33, + 150.67 + ], + [ + 161.33, + 172 + ], + [ + 186.67, + 190.67 + ], + [ + 237.33, + 200 + ], + [ + 280, + 216 + ], + [ + 289.33, + 257.33 + ], + [ + 366.67, + 278.67 + ], + [ + 418.67, + 294.67 + ], + [ + 449.33, + 328 + ], + [ + 462.67, + 380 + ], + [ + 501.33, + 414.67 + ], + [ + 537.33, + 404 + ], + [ + 548, + 382.67 + ], + [ + 556, + 352 + ], + [ + 542.67, + 324 + ], + [ + 534.67, + 292 + ], + [ + 578.67, + 284 + ], + [ + 597.33, + 308 + ], + [ + 606.67, + 344 + ], + [ + 589.33, + 374.67 + ], + [ + 604, + 410.67 + ], + [ + 632, + 396 + ], + [ + 660, + 373.33 + ], + [ + 690.67, + 334.67 + ], + [ + 672, + 289.33 + ], + [ + 697.33, + 258.67 + ], + [ + 718.67, + 250.67 + ], + [ + 721.33, + 212 + ], + [ + 724, + 178.67 + ], + [ + 729.33, + 154.67 + ], + [ + 764, + 152 + ], + [ + 780, + 185.33 + ], + [ + 808, + 206.67 + ], + [ + 750.67, + 228 + ], + [ + 760, + 248 + ], + [ + 737.33, + 292 + ], + [ + 726.67, + 337.33 + ], + [ + 729.33, + 364 + ], + [ + 693.33, + 396 + ], + [ + 658.67, + 424 + ], + [ + 660, + 453.33 + ], + [ + 673.33, + 508 + ], + [ + 698.67, + 530.67 + ], + [ + 758.67, + 544 + ], + [ + 754.67, + 581.33 + ], + [ + 780, + 617.33 + ], + [ + 820, + 632 + ], + [ + 874.67, + 621.33 + ], + [ + 928, + 594.67 + ], + [ + 965.33, + 586.67 + ], + [ + 981.33, + 529.33 + ], + [ + 1002.67, + 518.67 + ], + [ + 1042.67, + 532 + ], + [ + 1040, + 581.33 + ], + [ + 1010.67, + 628 + ], + [ + 1036, + 650.67 + ], + [ + 1093.33, + 658.67 + ], + [ + 1150.67, + 669.33 + ], + [ + 1201.33, + 696 + ], + [ + 1234.67, + 713.33 + ], + [ + 1278.67, + 708 + ], + [ + 1306.67, + 720 + ], + [ + 1369.33, + 736 + ], + [ + 1386.67, + 745.33 + ], + [ + 1385.33, + 773.33 + ], + [ + 1350.67, + 770.67 + ], + [ + 1310.67, + 753.33 + ], + [ + 1250.67, + 750.67 + ], + [ + 1208, + 757.33 + ], + [ + 1166.67, + 733.33 + ], + [ + 1114.67, + 697.33 + ], + [ + 1056, + 697.33 + ], + [ + 1001.33, + 684 + ], + [ + 976, + 640 + ], + [ + 934.67, + 640 + ], + [ + 897.33, + 644 + ], + [ + 872, + 668 + ], + [ + 818.67, + 681.33 + ], + [ + 780, + 704 + ], + [ + 772, + 734.67 + ], + [ + 740, + 788 + ], + [ + 698.67, + 801.33 + ], + [ + 670.67, + 832 + ], + [ + 649.33, + 857.33 + ], + [ + 676, + 898.67 + ], + [ + 697.33, + 924 + ], + [ + 733.33, + 960 + ], + [ + 785.33, + 993.33 + ], + [ + 821.33, + 1008 + ], + [ + 866.67, + 1030.67 + ], + [ + 913.33, + 1033.33 + ], + [ + 946.67, + 1029.33 + ], + [ + 952, + 990.67 + ], + [ + 1004, + 990.67 + ], + [ + 1004, + 1022.67 + ], + [ + 1004, + 1034.67 + ], + [ + 1014.67, + 1076 + ], + [ + 948, + 1080 + ], + [ + 858.67, + 1076 + ], + [ + 802.67, + 1076 + ], + [ + 782.67, + 1049.33 + ], + [ + 762.67, + 1018.67 + ], + [ + 724, + 1005.33 + ], + [ + 688, + 968 + ], + [ + 664, + 956 + ], + [ + 637.33, + 928 + ], + [ + 606.67, + 886.67 + ], + [ + 588, + 872 + ], + [ + 562.67, + 872 + ], + [ + 530.67, + 878.67 + ], + [ + 492, + 876 + ], + [ + 450.67, + 862.67 + ], + [ + 432, + 882.67 + ], + [ + 437.33, + 925.33 + ], + [ + 396, + 944 + ], + [ + 409.33, + 974.67 + ], + [ + 378.67, + 966.67 + ], + [ + 368, + 952 + ], + [ + 373.33, + 941.33 + ], + [ + 376, + 929.33 + ], + [ + 405.33, + 893.33 + ], + [ + 386.67, + 846.67 + ], + [ + 362.67, + 818.67 + ], + [ + 348, + 777.33 + ], + [ + 320, + 764 + ], + [ + 276, + 790.67 + ], + [ + 261.33, + 833.33 + ], + [ + 257.33, + 876 + ], + [ + 226.67, + 890.67 + ], + [ + 208, + 925.33 + ], + [ + 188, + 946.67 + ], + [ + 180, + 993.33 + ], + [ + 173.33, + 1008 + ], + [ + 124, + 1018.67 + ], + [ + 116, + 970.67 + ], + [ + 100, + 926.67 + ], + [ + 84, + 906.67 + ], + [ + 105.33, + 836 + ], + [ + 169.33, + 840 + ], + [ + 198.67, + 860 + ], + [ + 236, + 853.33 + ], + [ + 204, + 813.33 + ], + [ + 232, + 790.67 + ], + [ + 244, + 776 + ], + [ + 289.33, + 758.67 + ], + [ + 308, + 745.33 + ], + [ + 289.33, + 714.67 + ], + [ + 264, + 694.67 + ], + [ + 258.67, + 664 + ], + [ + 273.33, + 633.33 + ], + [ + 205.33, + 629.33 + ], + [ + 158.67, + 609.33 + ], + [ + 153.33, + 586.67 + ], + [ + 116, + 553.33 + ], + [ + 77.33, + 562.67 + ], + [ + 57.33, + 548 + ], + [ + 41.33, + 509.33 + ], + [ + 37.33, + 489.33 + ], + [ + 44, + 430.67 + ], + [ + 18.67, + 376 + ], + [ + 49.33, + 322.67 + ], + [ + 49.33, + 281.33 + ], + [ + 49.33, + 230.67 + ], + [ + 25.33, + 200 + ], + [ + 1.33, + 124 + ] + ] + }, + { + "id": "town", + "movementCost": 1, + "points": [ + [ + 968, + 964 + ], + [ + 954.67, + 924 + ], + [ + 934.67, + 905.33 + ], + [ + 873.33, + 857.33 + ], + [ + 853.33, + 856 + ], + [ + 866.67, + 812 + ], + [ + 869.33, + 809.33 + ], + [ + 898.67, + 804 + ], + [ + 920, + 782.67 + ], + [ + 950.67, + 780 + ], + [ + 949.33, + 813.33 + ], + [ + 1008, + 816 + ], + [ + 1038.67, + 798.67 + ], + [ + 1044, + 768 + ], + [ + 1090.67, + 784 + ], + [ + 1098.67, + 817.33 + ], + [ + 1060, + 844 + ], + [ + 1046.67, + 878.67 + ], + [ + 1020, + 901.33 + ], + [ + 1012, + 946.67 + ], + [ + 1000, + 990.67 + ], + [ + 960, + 989.33 + ], + [ + 960, + 953.33 + ] + ] + }, + { + "id": "lavaRoad", + "movementCost": 1, + "points": [ + [ + 1385.33, + 746.67 + ], + [ + 1436, + 757.33 + ], + [ + 1480, + 792 + ], + [ + 1530.67, + 776 + ], + [ + 1597.33, + 738.67 + ], + [ + 1636, + 712 + ], + [ + 1694.67, + 672 + ], + [ + 1708, + 630.67 + ], + [ + 1753.33, + 606.67 + ], + [ + 1733.33, + 564 + ], + [ + 1700, + 506.67 + ], + [ + 1712, + 450.67 + ], + [ + 1717.33, + 450.67 + ], + [ + 1758.67, + 448 + ], + [ + 1816, + 484 + ], + [ + 1864, + 518.67 + ], + [ + 1826.67, + 564 + ], + [ + 1804, + 588 + ], + [ + 1817.33, + 620 + ], + [ + 1758.67, + 657.33 + ], + [ + 1732, + 704 + ], + [ + 1650.67, + 754.67 + ], + [ + 1614.67, + 781.33 + ], + [ + 1548, + 816 + ], + [ + 1510.67, + 848 + ], + [ + 1496, + 892 + ], + [ + 1436, + 933.33 + ], + [ + 1389.33, + 944 + ], + [ + 1342.67, + 941.33 + ], + [ + 1278.67, + 952 + ], + [ + 1273.33, + 908 + ], + [ + 1317.33, + 905.33 + ], + [ + 1392, + 886.67 + ], + [ + 1437.33, + 857.33 + ], + [ + 1449.33, + 821.33 + ], + [ + 1421.33, + 805.33 + ], + [ + 1385.33, + 770.67 + ] + ] + } + ] + }, + "pointsOfInterest": [ + { + "actionLabel": "Read posts", + "dialogKey": "slop-swamp", + "enabled": true, + "href": "/posts", + "id": "slop-swamp", + "kind": "posts", + "label": "Slop Swamp", + "markerRadius": 52, + "menuOrder": 10, + "nodeId": "swamp", + "opensDialog": true, + "region": "The morass of x not y.", + "triggerBounds": { + "h": 268, + "w": 488, + "x": 90, + "y": 750 + }, + "triggerRadius": 120, + "triggerShape": "ellipse", + "x": 334, + "y": 884 + }, + { + "actionLabel": "View events", + "dialogKey": "village", + "enabled": true, + "href": "/events", + "id": "village", + "kind": "events", + "label": "The Village", + "markerRadius": 48, + "menuOrder": 40, + "nodeId": "village", + "opensDialog": true, + "region": "A hub of infinite meetings.", + "triggerBounds": { + "h": 146, + "w": 206, + "x": 878, + "y": 780 + }, + "triggerRadius": 96, + "triggerShape": "ellipse", + "x": 981, + "y": 853 + }, + { + "actionLabel": "View modules", + "dialogKey": "lava-castle", + "enabled": true, + "href": "/modules", + "id": "lava-castle", + "kind": "modules", + "label": "Dungeon of Distant Modules", + "markerRadius": 44, + "menuOrder": 20, + "nodeId": "lava-castle", + "opensDialog": true, + "region": "Floor is lava!", + "triggerRadius": 96, + "triggerShape": "circle", + "x": 1772, + "y": 522 + }, + { + "actionLabel": "Open wiki", + "dialogKey": "forest-knowledge", + "enabled": true, + "href": "/wiki", + "id": "forest-knowledge", + "kind": "wiki", + "label": "Forest of Unknown Knowledge", + "markerRadius": 42, + "menuOrder": 30, + "nodeId": "forest", + "opensDialog": true, + "region": "Wiki-willow of shadowy facts.", + "triggerRadius": 88, + "triggerShape": "circle", + "x": 748, + "y": 174 + }, + { + "actionLabel": "Give feedback", + "dialogKey": "whispers-hut", + "enabled": true, + "href": "/feedback", + "id": "whispers-hut", + "kind": "feedback", + "label": "Hut of Helpless Whispers", + "markerRadius": 40, + "menuOrder": 60, + "nodeId": "hut", + "opensDialog": true, + "region": "Speak your mind to the uncaring winds.", + "triggerRadius": 80, + "triggerShape": "circle", + "x": 562, + "y": 322 + }, + { + "actionLabel": "Check in", + "dialogKey": "lunker-lake", + "enabled": true, + "href": "", + "id": "lunker-lake", + "kind": "daily-engagement", + "label": "The Pond of Points", + "markerRadius": 46, + "menuOrder": 70, + "nodeId": "lake", + "opensDialog": true, + "region": "Cast a line into the lake each day to see what you catch.", + "triggerRadius": 104, + "triggerShape": "circle", + "x": 94, + "y": 340 + }, + { + "actionLabel": "Visit profile", + "dialogKey": "member-gate", + "enabled": false, + "href": "/me", + "id": "member-gate", + "kind": "profile", + "label": "Member Gate", + "markerRadius": 36, + "menuOrder": 80, + "nodeId": "mine", + "opensDialog": true, + "region": "Enter the mines. Does work and gems await?", + "triggerRadius": 72, + "triggerShape": "circle", + "x": 1018, + "y": 554 + }, + { + "actionLabel": "Approach castle", + "dialogKey": "guild-castle", + "enabled": true, + "href": "", + "id": "guild-castle", + "kind": "static", + "label": "Guild Castle", + "markerRadius": 44, + "menuOrder": 50, + "nodeId": "mine", + "opensDialog": true, + "region": "Queen Raida resides over the hall of mercenaries.", + "triggerRadius": 96, + "triggerShape": "circle", + "x": 1136, + "y": 258 + } + ], + "schema": "portal-map-v1", + "schemaVersion": 1, + "size": { + "h": 1080, + "w": 1920 + }, + "spawn": { + "characterFootRadius": 14, + "facing": "down", + "id": "default", + "x": 786, + "y": 660 + } +} diff --git a/scripts/convert-tiled-map.mjs b/scripts/convert-tiled-map.mjs new file mode 100644 index 0000000..b9fd9e9 --- /dev/null +++ b/scripts/convert-tiled-map.mjs @@ -0,0 +1,155 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' + +const defaultInput = 'public/assets/map/maps/adventure/adventure.tiled.json' +const defaultOutput = 'public/assets/map/maps/adventure/map.json' + +const inputPath = process.argv[2] || defaultInput +const outputPath = process.argv[3] || defaultOutput + +const round = (value) => Math.round(value * 100) / 100 +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)) + +const propertiesToObject = (properties = []) => + Object.fromEntries(properties.map((property) => [property.name, property.value])) + +const requireLayer = (map, name) => { + const layer = map.layers?.find((candidate) => candidate.name === name) + + if (!layer) { + throw new Error(`Missing required Tiled layer: ${name}`) + } + + return layer +} + +const absolutePolygon = (object, size) => { + if (!Array.isArray(object.polygon) || object.polygon.length < 3) { + throw new Error(`Object "${object.name}" must be a polygon with at least 3 points.`) + } + + return object.polygon.map((point) => [ + round(clamp(object.x + point.x, 0, size.w)), + round(clamp(object.y + point.y, 0, size.h)), + ]) +} + +const pointForObject = (object) => { + if (object.point) return { x: round(object.x), y: round(object.y) } + + if (object.ellipse || object.width || object.height) { + return { + x: round(object.x + object.width / 2), + y: round(object.y + object.height / 2), + } + } + + return { x: round(object.x), y: round(object.y) } +} + +const boundsForObject = (object) => { + if (!object.width && !object.height) return undefined + + return { + h: round(object.height || 0), + w: round(object.width || 0), + x: round(object.x), + y: round(object.y), + } +} + +const convert = (tiledMap) => { + const mapProperties = propertiesToObject(tiledMap.properties) + const sourceWidth = Number(mapProperties.sourceWidth || tiledMap.width * tiledMap.tilewidth) + const sourceHeight = Number(mapProperties.sourceHeight || tiledMap.height * tiledMap.tileheight) + const spawnLayer = requireLayer(tiledMap, 'spawn') + const walkableLayer = requireLayer(tiledMap, 'walkable') + const blockedLayer = requireLayer(tiledMap, 'blocked') + const poiLayer = requireLayer(tiledMap, 'pointsOfInterest') + const spawnObject = spawnLayer.objects?.[0] + + if (!spawnObject) { + throw new Error('The spawn layer must include a default spawn point.') + } + + const spawnProperties = propertiesToObject(spawnObject.properties) + const spawnPoint = pointForObject(spawnObject) + + return { + background: + mapProperties.backgroundPath || '/assets/map/backgrounds/adventure-map-background.webp', + movement: { + blocked: (blockedLayer.objects || []).map((object) => { + const properties = propertiesToObject(object.properties) + + return { + id: String(properties.obstacleId || object.name), + points: absolutePolygon(object, { h: sourceHeight, w: sourceWidth }), + } + }), + kind: String(mapProperties.movementKind || 'navmesh'), + walkable: (walkableLayer.objects || []).map((object) => { + const properties = propertiesToObject(object.properties) + + return { + id: String(properties.navmeshId || object.name), + movementCost: Number(properties.movementCost || 1), + points: absolutePolygon(object, { h: sourceHeight, w: sourceWidth }), + } + }), + }, + pointsOfInterest: (poiLayer.objects || []).map((object) => { + const properties = propertiesToObject(object.properties) + const point = pointForObject(object) + const triggerBounds = boundsForObject(object) + + return { + actionLabel: String(properties.actionLabel || ''), + dialogKey: String(properties.dialogKey || properties.locationId || object.name), + enabled: properties.enabled !== false, + href: String(properties.href || ''), + id: String(properties.locationId || object.name), + kind: String(properties.kind || 'static'), + label: String(properties.label || object.name), + markerRadius: Number(properties.markerRadius || 36), + menuOrder: Number(properties.menuOrder || 100), + nodeId: String(properties.nodeId || ''), + opensDialog: properties.opensDialog !== false, + region: String(properties.region || ''), + triggerBounds, + triggerRadius: Number(properties.triggerRadius || 72), + triggerShape: String(properties.triggerShape || (object.ellipse ? 'ellipse' : 'circle')), + x: point.x, + y: point.y, + } + }), + schema: String(mapProperties.schema || 'portal-map-v1'), + schemaVersion: Number(mapProperties.schemaVersion || 1), + size: { + h: sourceHeight, + w: sourceWidth, + }, + spawn: { + characterFootRadius: Number(spawnProperties.characterFootRadius || 14), + facing: String(spawnProperties.facing || 'down'), + id: String(spawnProperties.spawnId || spawnObject.name || 'default'), + x: spawnPoint.x, + y: spawnPoint.y, + }, + } +} + +const main = async () => { + const input = JSON.parse(await readFile(inputPath, 'utf8')) + const manifest = convert(input) + + await mkdir(path.dirname(outputPath), { recursive: true }) + await writeFile(outputPath, `${JSON.stringify(manifest, null, 2)}\n`) + console.log(`Wrote ${outputPath}`) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/scripts/validate-map-manifest.mjs b/scripts/validate-map-manifest.mjs new file mode 100644 index 0000000..d6bdb6b --- /dev/null +++ b/scripts/validate-map-manifest.mjs @@ -0,0 +1,164 @@ +import { access, readFile } from 'node:fs/promises' +import process from 'node:process' + +const defaultInput = 'public/assets/map/maps/adventure/map.json' +const inputPath = process.argv[2] || defaultInput + +const mapLocationIds = new Set([ + 'forest-knowledge', + 'guild-castle', + 'lava-castle', + 'lunker-lake', + 'slop-swamp', + 'village', + 'whispers-hut', +]) + +const assert = (condition, message) => { + if (!condition) throw new Error(message) +} + +const pointInPolygon = (point, polygon) => { + let inside = false + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const [xi, yi] = polygon[i] + const [xj, yj] = polygon[j] + const intersects = + yi > point.y !== yj > point.y && + point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi + + if (intersects) inside = !inside + } + + return inside +} + +const distanceToSegment = (point, start, end) => { + const [x1, y1] = start + const [x2, y2] = end + const dx = x2 - x1 + const dy = y2 - y1 + const lengthSquared = dx * dx + dy * dy + + if (lengthSquared === 0) return Math.hypot(point.x - x1, point.y - y1) + + const t = Math.max(0, Math.min(1, ((point.x - x1) * dx + (point.y - y1) * dy) / lengthSquared)) + const projectionX = x1 + t * dx + const projectionY = y1 + t * dy + + return Math.hypot(point.x - projectionX, point.y - projectionY) +} + +const distanceToPolygonEdges = (point, polygon) => { + let closest = Number.POSITIVE_INFINITY + + for (let i = 0; i < polygon.length; i += 1) { + closest = Math.min(closest, distanceToSegment(point, polygon[i], polygon[(i + 1) % polygon.length])) + } + + return closest +} + +const circleInsidePolygon = (point, polygon, radius) => + pointInPolygon(point, polygon) && distanceToPolygonEdges(point, polygon) >= radius + +const circleIntersectsPolygon = (point, polygon, radius) => + pointInPolygon(point, polygon) || distanceToPolygonEdges(point, polygon) <= radius + +const validatePolygon = (polygon, label, manifest) => { + assert(Array.isArray(polygon), `${label} must be an array of points.`) + assert(polygon.length >= 3, `${label} must have at least 3 points.`) + + for (const point of polygon) { + assert(Array.isArray(point) && point.length === 2, `${label} has an invalid point.`) + const [x, y] = point + assert(Number.isFinite(x) && Number.isFinite(y), `${label} has a non-numeric point.`) + assert(x >= -1 && x <= manifest.size.w + 1, `${label} has x outside source bounds.`) + assert(y >= -1 && y <= manifest.size.h + 1, `${label} has y outside source bounds.`) + } +} + +const validate = async (manifest) => { + assert(manifest.schema === 'portal-map-v1', 'Expected schema portal-map-v1.') + assert(manifest.schemaVersion === 1, 'Expected schemaVersion 1.') + assert(manifest.size?.w === 1920, 'Expected source width 1920.') + assert(manifest.size?.h === 1080, 'Expected source height 1080.') + assert(typeof manifest.background === 'string' && manifest.background, 'Missing background path.') + + await access(`public${manifest.background}`) + + assert(manifest.spawn?.id === 'default', 'Expected one default spawn.') + assert(Number.isFinite(manifest.spawn.x), 'Spawn x must be numeric.') + assert(Number.isFinite(manifest.spawn.y), 'Spawn y must be numeric.') + assert( + Number.isFinite(manifest.spawn.characterFootRadius) && + manifest.spawn.characterFootRadius > 0, + 'Spawn characterFootRadius must be positive.', + ) + + const walkable = manifest.movement?.walkable || [] + const blocked = manifest.movement?.blocked || [] + + assert(manifest.movement?.kind === 'navmesh', 'Expected navmesh movement.') + assert(walkable.length > 0, 'At least one walkable polygon is required.') + + for (const polygon of walkable) { + assert(polygon.id, 'Every walkable polygon needs an id.') + validatePolygon(polygon.points, `walkable:${polygon.id}`, manifest) + } + + for (const polygon of blocked) { + assert(polygon.id, 'Every blocked polygon needs an id.') + validatePolygon(polygon.points, `blocked:${polygon.id}`, manifest) + } + + const spawnPoint = { x: manifest.spawn.x, y: manifest.spawn.y } + const spawnRadius = manifest.spawn.characterFootRadius + assert( + walkable.some((polygon) => circleInsidePolygon(spawnPoint, polygon.points, spawnRadius)), + 'Default spawn footprint must fit inside a walkable polygon.', + ) + assert( + !blocked.some((polygon) => circleIntersectsPolygon(spawnPoint, polygon.points, spawnRadius)), + 'Default spawn footprint must avoid blocked polygons.', + ) + + for (const poi of manifest.pointsOfInterest || []) { + assert(poi.id, 'Every POI needs an id.') + assert(poi.label, `POI ${poi.id} needs a label.`) + assert(poi.kind, `POI ${poi.id} needs a kind.`) + assert(poi.dialogKey, `POI ${poi.id} needs a dialogKey.`) + assert(Number.isFinite(poi.x) && Number.isFinite(poi.y), `POI ${poi.id} needs coordinates.`) + assert(Number.isFinite(poi.triggerRadius), `POI ${poi.id} needs a triggerRadius.`) + assert( + poi.triggerShape === 'circle' || poi.triggerShape === 'ellipse', + `POI ${poi.id} triggerShape must be circle or ellipse.`, + ) + if (poi.triggerShape === 'ellipse') { + assert( + Number.isFinite(poi.triggerBounds?.w) && + poi.triggerBounds.w > 0 && + Number.isFinite(poi.triggerBounds?.h) && + poi.triggerBounds.h > 0, + `POI ${poi.id} ellipse triggerBounds must have positive width and height.`, + ) + } + assert(Number.isFinite(poi.markerRadius), `POI ${poi.id} needs a markerRadius.`) + + if (poi.enabled) { + assert(mapLocationIds.has(poi.id), `Enabled POI ${poi.id} is not a map dialog id.`) + } + } +} + +const main = async () => { + const manifest = JSON.parse(await readFile(inputPath, 'utf8')) + await validate(manifest) + console.log(`Validated ${inputPath}`) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/src/app/(frontend)/dashboard/map/MapDashboardClient.tsx b/src/app/(frontend)/dashboard/map/MapDashboardClient.tsx index 49ba031..3d9efcf 100644 --- a/src/app/(frontend)/dashboard/map/MapDashboardClient.tsx +++ b/src/app/(frontend)/dashboard/map/MapDashboardClient.tsx @@ -1,13 +1,22 @@ 'use client' import Link from 'next/link' -import { ArrowLeft, MapPin, UserRound } from 'lucide-react' +import { + ArrowDown, + ArrowLeft as ArrowLeftIcon, + ArrowRight, + ArrowUp, + MapPin, + UserRound, +} from 'lucide-react' import React, { useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' import type { User } from '@/payload-types' import { cn } from '@/utilities/cn' import type { SelectableMapRole, MapDashboardData } from './mapData' +import { getNearestTriggeredPOI } from './mapGeometry' +import { mapManifest } from './mapManifest' import { mapBackgroundPath, mapLocations, @@ -15,9 +24,11 @@ import { type MapLocationID, } from './mapConfig' import { MapCharacterSelector } from './MapCharacterSelector' +import { MapDebugOverlay } from './MapDebugOverlay' +import { MapInteractionPrompt } from './MapInteractionPrompt' import { MapLocationDialog } from './MapLocationDialog' import { MapSprite } from './MapSprite' -import { useMapMovement } from './useMapMovement' +import { useFreeWalkMovement } from './useFreeWalkMovement' type MapDashboardClientProps = { data: MapDashboardData @@ -25,25 +36,49 @@ type MapDashboardClientProps = { user: User } +const isInteractiveEventTarget = (target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) return false + + return Boolean( + target.closest( + 'a, button, input, textarea, select, summary, [role="button"], [role="link"], [tabindex]', + ), + ) +} + export const MapDashboardClient: React.FC = ({ data, fontClassName, user, }) => { - const movement = useMapMovement() const storageKey = `portal-map-character:${data.profile?.id || user.id}` const [activeLocationID, setActiveLocationID] = useState(null) const [hasLoadedCharacter, setHasLoadedCharacter] = useState(false) + const [isDebugOverlayVisible, setIsDebugOverlayVisible] = useState(false) const [isSelectorOpen, setIsSelectorOpen] = useState(false) const [selectedRole, setSelectedRole] = useState(null) + const isMovementEnabled = Boolean(selectedRole) && !isSelectorOpen && !activeLocationID + const movement = useFreeWalkMovement({ + enabled: isMovementEnabled, + manifest: mapManifest, + }) const activeLocation = useMemo( () => mapLocations.find((location) => location.id === activeLocationID) || null, [activeLocationID], ) + const nearbyPOI = useMemo( + () => getNearestTriggeredPOI(movement.sourcePosition, mapManifest.pointsOfInterest), + [movement.sourcePosition], + ) + const nearbyLocation = useMemo( + () => mapLocations.find((location) => location.id === nearbyPOI?.id) || null, + [nearbyPOI], + ) const selectedLabel = selectedRole ? `${selectedRole.title} form` : 'Guild character' useEffect(() => { document.body.classList.add('map-dashboard-fullscreen') + setIsDebugOverlayVisible(new URLSearchParams(window.location.search).get('mapDebug') === '1') return () => { document.body.classList.remove('map-dashboard-fullscreen') @@ -66,6 +101,21 @@ export const MapDashboardClient: React.FC = ({ setHasLoadedCharacter(true) }, [data.selectableRoles, storageKey]) + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (!nearbyLocation || !isMovementEnabled) return + if (event.key !== 'Enter' && event.key !== ' ') return + if (nearbyLocation.disabled || isInteractiveEventTarget(event.target)) return + + event.preventDefault() + setActiveLocationID(nearbyLocation.id) + } + + window.addEventListener('keydown', onKeyDown) + + return () => window.removeEventListener('keydown', onKeyDown) + }, [isMovementEnabled, nearbyLocation]) + const selectRole = (role: SelectableMapRole) => { if (!role.available || !role.spriteSlug) return @@ -82,9 +132,16 @@ export const MapDashboardClient: React.FC = ({ if (location.disabled) return - movement.travelTo(location.nodeID, () => setActiveLocationID(location.id)) + setActiveLocationID(location.id) } + const directionButtons = [ + { direction: 'up' as const, icon: ArrowUp, label: 'Move up' }, + { direction: 'left' as const, icon: ArrowLeftIcon, label: 'Move left' }, + { direction: 'right' as const, icon: ArrowRight, label: 'Move right' }, + { direction: 'down' as const, icon: ArrowDown, label: 'Move down' }, + ] + return (
@@ -95,12 +152,13 @@ export const MapDashboardClient: React.FC = ({ src={mapBackgroundPath} />
+ {isDebugOverlayVisible ? : null} {mapLocations.map((location) => (
@@ -131,13 +189,19 @@ export const MapDashboardClient: React.FC = ({
- {movement.isMoving ? 'Traveling' : selectedRole ? selectedRole.title : 'Choose form'} + {nearbyLocation + ? `Near ${nearbyLocation.label}` + : movement.isMoving + ? 'Free walking' + : selectedRole + ? selectedRole.title + : 'Choose form'}
@@ -154,6 +218,37 @@ export const MapDashboardClient: React.FC = ({
+
+
+ {directionButtons.map(({ direction, icon: Icon, label }) => ( + + ))} +
+
+ + {nearbyPOI && nearbyLocation && !nearbyLocation.disabled && isMovementEnabled ? ( + setActiveLocationID(nearbyLocation.id)} + poi={nearbyPOI} + /> + ) : null} + {isSelectorOpen ? ( = ({ manifest }) => ( + +) diff --git a/src/app/(frontend)/dashboard/map/MapDialog.tsx b/src/app/(frontend)/dashboard/map/MapDialog.tsx index e4737c1..0d9f84a 100644 --- a/src/app/(frontend)/dashboard/map/MapDialog.tsx +++ b/src/app/(frontend)/dashboard/map/MapDialog.tsx @@ -102,7 +102,7 @@ export const MapDialog: React.FC = ({
-

RaidGuild Map

+

RaidGuild Realm

{title}

diff --git a/src/app/(frontend)/dashboard/map/MapInteractionPrompt.tsx b/src/app/(frontend)/dashboard/map/MapInteractionPrompt.tsx new file mode 100644 index 0000000..7a0e5d2 --- /dev/null +++ b/src/app/(frontend)/dashboard/map/MapInteractionPrompt.tsx @@ -0,0 +1,23 @@ +'use client' + +import { CornerDownLeft } from 'lucide-react' +import React from 'react' + +import { Button } from '@/components/ui/button' +import type { MapManifestPointOfInterest } from './mapManifest' + +export const MapInteractionPrompt: React.FC<{ + onInteract: () => void + poi: MapManifestPointOfInterest +}> = ({ onInteract, poi }) => ( +
+
+

{poi.label}

+ {poi.region ? {poi.region} : null} +
+ +
+) diff --git a/src/app/(frontend)/dashboard/map/mapConfig.ts b/src/app/(frontend)/dashboard/map/mapConfig.ts index c81247c..dff95dc 100644 --- a/src/app/(frontend)/dashboard/map/mapConfig.ts +++ b/src/app/(frontend)/dashboard/map/mapConfig.ts @@ -1,3 +1,6 @@ +import { toPercentPoint } from './mapGeometry' +import { mapManifest } from './mapManifest' + export type MapLocationID = | 'slop-swamp' | 'lava-castle' @@ -31,6 +34,7 @@ export type MapNode = MapPoint & { export type MapLocationConfig = MapPoint & { actionHref?: string + actionLabel?: string disabled?: boolean id: MapLocationID label: string @@ -40,12 +44,9 @@ export type MapLocationConfig = MapPoint & { export type SpriteDirection = 'down' | 'up' | 'left' | 'right' -export const mapBackgroundPath = '/assets/map/backgrounds/adventure-map-background.webp' +export const mapBackgroundPath = mapManifest.background -export const mapSpawnPoint: MapPoint = { - x: 49, - y: 64, -} +export const mapSpawnPoint: MapPoint = toPercentPoint(mapManifest.spawn, mapManifest.size) export const roleSpriteAliases: Record = { 'angry-dwarf': 'dwarf', @@ -136,69 +137,57 @@ export const mapNodes: Record = { }, } -export const mapLocations: MapLocationConfig[] = [ - { - actionHref: '/posts', - id: 'slop-swamp', - label: 'Slop Swamp', - nodeID: 'swamp', - region: 'Lower-left swamp', - x: 18, - y: 75, - }, - { - actionHref: '/modules', - id: 'lava-castle', - label: 'Lava Castle', - nodeID: 'lava-castle', - region: 'Far-right castle', - x: 91, - y: 51, - }, - { - actionHref: '/wiki', - id: 'forest-knowledge', - label: 'Forest of Unknown Knowledge', - nodeID: 'forest', - region: 'Tree entry', - x: 33, - y: 23, - }, - { - actionHref: '/events', - id: 'village', - label: 'The Village', - nodeID: 'village', - region: 'Lower-middle town', - x: 51, - y: 78, - }, - { - id: 'guild-castle', - label: 'Guild Castle', - nodeID: 'mine', - region: 'Main mountain castle', - x: 58, - y: 15, - }, - { - actionHref: '/feedback', - id: 'whispers-hut', - label: 'Hut of Helpless Whispers', - nodeID: 'hut', - region: 'Forest hut', - x: 29, - y: 34, - }, - { - id: 'lunker-lake', - label: 'Lunker Lake', - nodeID: 'lake', - region: 'Lake on the left side', - x: 12, - y: 40, - }, -] +const mapLocationIDs = new Set([ + 'forest-knowledge', + 'guild-castle', + 'lava-castle', + 'lunker-lake', + 'slop-swamp', + 'village', + 'whispers-hut', +]) + +const mapNodeIDs = new Set([ + 'forest', + 'hut', + 'lake', + 'lava-castle', + 'lava-road', + 'mine', + 'south-road', + 'spawn', + 'swamp', + 'village', +]) + +const isMapLocationID = (value: string): value is MapLocationID => + mapLocationIDs.has(value as MapLocationID) + +const toMapNodeID = (value: string): MapNodeID => { + if (mapNodeIDs.has(value as MapNodeID)) return value as MapNodeID + + throw new Error(`Unknown map node ID in map manifest: ${value}`) +} + +export const mapLocations: MapLocationConfig[] = mapManifest.pointsOfInterest + .filter((poi) => isMapLocationID(poi.id)) + .sort((a, b) => a.menuOrder - b.menuOrder) + .map((poi) => { + const id = poi.id as MapLocationID + const point = toPercentPoint(poi, mapManifest.size) + + return { + actionHref: poi.href || undefined, + actionLabel: poi.actionLabel || undefined, + disabled: !poi.enabled, + id, + label: poi.label, + nodeID: toMapNodeID(poi.nodeId), + region: poi.region, + x: point.x, + y: point.y, + } + }) export const findPath = (from: MapNodeID, to: MapNodeID): MapNodeID[] => { if (from === to) return [from] diff --git a/src/app/(frontend)/dashboard/map/mapGeometry.ts b/src/app/(frontend)/dashboard/map/mapGeometry.ts new file mode 100644 index 0000000..2bc5d99 --- /dev/null +++ b/src/app/(frontend)/dashboard/map/mapGeometry.ts @@ -0,0 +1,120 @@ +import type { MapManifest, MapManifestPointOfInterest, MapPixelPoint, MapPolygon } from './mapManifest' + +export const toPercentPoint = (point: MapPixelPoint, size: MapManifest['size']) => ({ + x: (point.x / size.w) * 100, + y: (point.y / size.h) * 100, +}) + +export const clampPointToMap = (point: MapPixelPoint, size: MapManifest['size']): MapPixelPoint => ({ + x: Math.min(size.w, Math.max(0, point.x)), + y: Math.min(size.h, Math.max(0, point.y)), +}) + +export const pointInPolygon = (point: MapPixelPoint, polygon: MapPolygon): boolean => { + let inside = false + + for (let i = 0, j = polygon.points.length - 1; i < polygon.points.length; j = i++) { + const [xi, yi] = polygon.points[i] + const [xj, yj] = polygon.points[j] + const intersects = + yi > point.y !== yj > point.y && + point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi + + if (intersects) inside = !inside + } + + return inside +} + +const distanceToSegment = ( + point: MapPixelPoint, + start: [number, number], + end: [number, number], +): number => { + const [x1, y1] = start + const [x2, y2] = end + const dx = x2 - x1 + const dy = y2 - y1 + const lengthSquared = dx * dx + dy * dy + + if (lengthSquared === 0) return Math.hypot(point.x - x1, point.y - y1) + + const t = Math.max(0, Math.min(1, ((point.x - x1) * dx + (point.y - y1) * dy) / lengthSquared)) + const projectionX = x1 + t * dx + const projectionY = y1 + t * dy + + return Math.hypot(point.x - projectionX, point.y - projectionY) +} + +const distanceToPolygonEdges = (point: MapPixelPoint, polygon: MapPolygon): number => { + let closest = Number.POSITIVE_INFINITY + + for (let i = 0; i < polygon.points.length; i += 1) { + const start = polygon.points[i] + const end = polygon.points[(i + 1) % polygon.points.length] + closest = Math.min(closest, distanceToSegment(point, start, end)) + } + + return closest +} + +const circleInsidePolygon = ( + point: MapPixelPoint, + polygon: MapPolygon, + radius: number, +): boolean => pointInPolygon(point, polygon) && distanceToPolygonEdges(point, polygon) >= radius + +const circleIntersectsPolygon = ( + point: MapPixelPoint, + polygon: MapPolygon, + radius: number, +): boolean => pointInPolygon(point, polygon) || distanceToPolygonEdges(point, polygon) <= radius + +export const canStandAtPoint = ( + manifest: MapManifest, + point: MapPixelPoint, + radius = manifest.spawn.characterFootRadius, +): boolean => { + const clamped = clampPointToMap(point, manifest.size) + + if (clamped.x !== point.x || clamped.y !== point.y) return false + + const isInsideWalkable = manifest.movement.walkable.some((polygon) => + circleInsidePolygon(point, polygon, radius), + ) + + if (!isInsideWalkable) return false + + return !manifest.movement.blocked.some((polygon) => circleIntersectsPolygon(point, polygon, radius)) +} + +export const isPointInPOITrigger = ( + point: MapPixelPoint, + poi: MapManifestPointOfInterest, +): boolean => { + if (poi.triggerShape === 'ellipse' && poi.triggerBounds?.w && poi.triggerBounds?.h) { + const radiusX = poi.triggerBounds.w / 2 + const radiusY = poi.triggerBounds.h / 2 + const normalizedX = (point.x - poi.x) / radiusX + const normalizedY = (point.y - poi.y) / radiusY + + return normalizedX * normalizedX + normalizedY * normalizedY <= 1 + } + + return Math.hypot(point.x - poi.x, point.y - poi.y) <= poi.triggerRadius +} + +export const getNearestTriggeredPOI = ( + point: MapPixelPoint, + pois: MapManifestPointOfInterest[], +): MapManifestPointOfInterest | null => { + const triggered = pois + .filter((poi) => poi.enabled && poi.opensDialog && isPointInPOITrigger(point, poi)) + .map((poi) => ({ + distance: Math.hypot(point.x - poi.x, point.y - poi.y), + poi, + })) + .sort((a, b) => a.distance - b.distance) + + return triggered[0]?.poi || null +} diff --git a/src/app/(frontend)/dashboard/map/mapManifest.ts b/src/app/(frontend)/dashboard/map/mapManifest.ts new file mode 100644 index 0000000..fd86c57 --- /dev/null +++ b/src/app/(frontend)/dashboard/map/mapManifest.ts @@ -0,0 +1,58 @@ +import manifest from '../../../../../public/assets/map/maps/adventure/map.json' + +export type MapPixelPoint = { + x: number + y: number +} + +export type MapPolygon = { + id: string + movementCost?: number + points: Array<[number, number]> +} + +export type MapManifestPointOfInterest = MapPixelPoint & { + actionLabel: string + dialogKey: string + enabled: boolean + href: string + id: string + kind: string + label: string + markerRadius: number + menuOrder: number + nodeId: string + opensDialog: boolean + region: string + triggerBounds?: { + h: number + w: number + x: number + y: number + } + triggerRadius: number + triggerShape: string +} + +export type MapManifest = { + background: string + movement: { + blocked: MapPolygon[] + kind: 'navmesh' + walkable: MapPolygon[] + } + pointsOfInterest: MapManifestPointOfInterest[] + schema: 'portal-map-v1' + schemaVersion: 1 + size: { + h: number + w: number + } + spawn: MapPixelPoint & { + characterFootRadius: number + facing: 'down' | 'left' | 'right' | 'up' + id: string + } +} + +export const mapManifest = manifest as MapManifest diff --git a/src/app/(frontend)/dashboard/map/useFreeWalkMovement.ts b/src/app/(frontend)/dashboard/map/useFreeWalkMovement.ts new file mode 100644 index 0000000..36f8528 --- /dev/null +++ b/src/app/(frontend)/dashboard/map/useFreeWalkMovement.ts @@ -0,0 +1,220 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import type { SpriteDirection } from './mapConfig' +import { canStandAtPoint, clampPointToMap, toPercentPoint } from './mapGeometry' +import type { MapManifest, MapPixelPoint } from './mapManifest' + +type HeldDirection = 'down' | 'left' | 'right' | 'up' + +const speedPixelsPerSecond = 230 + +const keyToDirection: Record = { + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', + ArrowUp: 'up', + a: 'left', + d: 'right', + s: 'down', + w: 'up', +} + +const isEditableTarget = (target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) return false + + return target.isContentEditable || Boolean(target.closest('input, textarea, select, [contenteditable]')) +} + +const getVector = (directions: Set) => { + const x = (directions.has('right') ? 1 : 0) - (directions.has('left') ? 1 : 0) + const y = (directions.has('down') ? 1 : 0) - (directions.has('up') ? 1 : 0) + + if (!x && !y) return { x: 0, y: 0 } + + const length = Math.hypot(x, y) + + return { + x: x / length, + y: y / length, + } +} + +const directionFromVector = (vector: MapPixelPoint, fallback: SpriteDirection): SpriteDirection => { + if (!vector.x && !vector.y) return fallback + + if (Math.abs(vector.x) > Math.abs(vector.y)) { + return vector.x > 0 ? 'right' : 'left' + } + + return vector.y > 0 ? 'down' : 'up' +} + +export const useFreeWalkMovement = ({ + enabled, + manifest, +}: { + enabled: boolean + manifest: MapManifest +}) => { + const [direction, setDirection] = useState(manifest.spawn.facing) + const [isMoving, setIsMoving] = useState(false) + const [sourcePosition, setSourcePosition] = useState({ + x: manifest.spawn.x, + y: manifest.spawn.y, + }) + const animationRef = useRef(null) + const heldDirectionsRef = useRef(new Set()) + const lastTimestampRef = useRef(null) + const positionRef = useRef(sourcePosition) + + const renderPosition = useMemo( + () => toPercentPoint(sourcePosition, manifest.size), + [manifest.size, sourcePosition], + ) + + const stopAnimation = useCallback(() => { + if (animationRef.current) { + window.cancelAnimationFrame(animationRef.current) + animationRef.current = null + } + + lastTimestampRef.current = null + setIsMoving(false) + }, []) + + const tryMove = useCallback( + (from: MapPixelPoint, vector: MapPixelPoint, deltaSeconds: number) => { + const distance = speedPixelsPerSecond * deltaSeconds + const desired = clampPointToMap( + { + x: from.x + vector.x * distance, + y: from.y + vector.y * distance, + }, + manifest.size, + ) + + if (canStandAtPoint(manifest, desired)) return desired + + const horizontal = clampPointToMap({ x: desired.x, y: from.y }, manifest.size) + if (canStandAtPoint(manifest, horizontal)) return horizontal + + const vertical = clampPointToMap({ x: from.x, y: desired.y }, manifest.size) + if (canStandAtPoint(manifest, vertical)) return vertical + + return from + }, + [manifest], + ) + + const tick = useCallback( + (timestamp: number) => { + if (!enabled) { + stopAnimation() + return + } + + const vector = getVector(heldDirectionsRef.current) + + if (!vector.x && !vector.y) { + stopAnimation() + return + } + + const lastTimestamp = lastTimestampRef.current ?? timestamp + const deltaSeconds = Math.min(0.06, Math.max(0, (timestamp - lastTimestamp) / 1000)) + const nextPosition = tryMove(positionRef.current, vector, deltaSeconds) + + lastTimestampRef.current = timestamp + positionRef.current = nextPosition + setSourcePosition(nextPosition) + setDirection((current) => directionFromVector(vector, current)) + setIsMoving(true) + animationRef.current = window.requestAnimationFrame(tick) + }, + [enabled, stopAnimation, tryMove], + ) + + const ensureAnimation = useCallback(() => { + if (!enabled || animationRef.current) return + + animationRef.current = window.requestAnimationFrame(tick) + }, [enabled, tick]) + + const setDirectionHeld = useCallback( + (directionToHold: HeldDirection, held: boolean) => { + if (!enabled) return + + if (held) { + heldDirectionsRef.current.add(directionToHold) + ensureAnimation() + } else { + heldDirectionsRef.current.delete(directionToHold) + } + }, + [enabled, ensureAnimation], + ) + + useEffect(() => { + if (enabled) return + + heldDirectionsRef.current.clear() + stopAnimation() + }, [enabled, stopAnimation]) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (!enabled || isEditableTarget(event.target)) return + + const directionToHold = keyToDirection[event.key] + if (!directionToHold) return + + event.preventDefault() + heldDirectionsRef.current.add(directionToHold) + ensureAnimation() + } + + const onKeyUp = (event: KeyboardEvent) => { + const directionToHold = keyToDirection[event.key] + if (!directionToHold) return + + heldDirectionsRef.current.delete(directionToHold) + } + + const clearHeldDirections = () => { + heldDirectionsRef.current.clear() + stopAnimation() + } + + const onVisibilityChange = () => { + if (document.visibilityState !== 'visible') clearHeldDirections() + } + + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', clearHeldDirections) + document.addEventListener('visibilitychange', onVisibilityChange) + + return () => { + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', clearHeldDirections) + document.removeEventListener('visibilitychange', onVisibilityChange) + } + }, [enabled, ensureAnimation, stopAnimation]) + + useEffect(() => { + positionRef.current = sourcePosition + }, [sourcePosition]) + + useEffect(() => stopAnimation, [stopAnimation]) + + return { + direction, + isMoving, + renderPosition, + setDirectionHeld, + sourcePosition, + } +} diff --git a/src/app/(frontend)/globals.css b/src/app/(frontend)/globals.css index 14ddfcf..bd82899 100644 --- a/src/app/(frontend)/globals.css +++ b/src/app/(frontend)/globals.css @@ -215,10 +215,18 @@ body.map-dashboard-fullscreen [data-portal-footer] { top: 0; } +.map-dashboard-hud-bottom { + bottom: 0; + left: 0; + padding: clamp(0.75rem, 2vw, 1.25rem); +} + .map-dashboard-status, .map-dashboard-button, .map-location-label, -.map-dialog-panel { +.map-dialog-panel, +.map-dpad-button, +.map-interaction-prompt { background: rgba(10, 5, 4, 0.86); border: 2px solid hsl(var(--scroll-300)); box-shadow: @@ -250,6 +258,84 @@ body.map-dashboard-fullscreen [data-portal-footer] { padding: 0.6rem 0.75rem; } +.map-dpad { + display: grid; + gap: 0.35rem; + grid-template-areas: + '. up .' + 'left . right' + '. down .'; + grid-template-columns: repeat(3, 2.75rem); + grid-template-rows: repeat(3, 2.75rem); + touch-action: none; +} + +.map-dpad-button { + align-items: center; + color: hsl(var(--scroll-100)); + display: flex; + height: 2.75rem; + justify-content: center; + outline: none; + width: 2.75rem; +} + +.map-dpad-button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.map-dpad-button:focus-visible { + outline: 2px solid hsl(var(--scroll-100)); + outline-offset: 3px; +} + +.map-dpad-button-up { + grid-area: up; +} + +.map-dpad-button-left { + grid-area: left; +} + +.map-dpad-button-right { + grid-area: right; +} + +.map-dpad-button-down { + grid-area: down; +} + +.map-interaction-prompt { + align-items: center; + bottom: clamp(0.75rem, 2vw, 1.25rem); + color: hsl(var(--scroll-100)); + display: flex; + gap: 0.75rem; + justify-content: space-between; + left: 50%; + max-width: min(92vw, 34rem); + padding: 0.65rem 0.75rem; + position: absolute; + text-transform: uppercase; + transform: translateX(-50%); + width: max-content; + z-index: 35; +} + +.map-interaction-prompt p { + font-size: clamp(0.5rem, 1vw, 0.68rem); + line-height: 1.45; +} + +.map-interaction-prompt span { + color: hsl(var(--scroll-200)); + display: block; + font-size: clamp(0.42rem, 0.78vw, 0.52rem); + line-height: 1.45; + margin-top: 0.2rem; +} + .map-location-marker { align-items: center; display: flex; @@ -311,6 +397,31 @@ body.map-dashboard-fullscreen [data-portal-footer] { justify-content: flex-start; } + .map-dashboard-hud-bottom { + bottom: 4.5rem; + padding-right: 0; + } + + .map-dpad { + grid-template-columns: repeat(3, 2.35rem); + grid-template-rows: repeat(3, 2.35rem); + } + + .map-dpad-button { + height: 2.35rem; + width: 2.35rem; + } + + .map-interaction-prompt { + align-items: stretch; + bottom: 0.75rem; + flex-direction: column; + left: 0.75rem; + right: 0.75rem; + transform: none; + width: auto; + } + .map-location-label { max-width: 7rem; } diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index 8b2f09b..264aec3 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -305,7 +305,17 @@ async function verifyMapDashboard(adminPage: Page, browser: Browser, publicPage: await mapPage.getByRole('button', { name: /warrior/i }).click() await expect(mapPage.getByRole('dialog', { name: /choose your guild form/i })).toHaveCount(0) - await expect(mapPage.getByLabel(/warrior form/i)).toBeVisible() + const warriorSprite = mapPage.getByLabel(/warrior form/i) + await expect(warriorSprite).toBeVisible() + + const beforeWalk = await warriorSprite.boundingBox() + expect(beforeWalk).toBeTruthy() + await mapPage.keyboard.down('ArrowRight') + await mapPage.waitForTimeout(350) + await mapPage.keyboard.up('ArrowRight') + const afterWalk = await warriorSprite.boundingBox() + expect(afterWalk).toBeTruthy() + expect(afterWalk!.x).toBeGreaterThan(beforeWalk!.x + 8) await mapPage.getByRole('button', { name: /travel to slop swamp/i }).click() await expect(mapPage.getByRole('dialog', { name: /slop swamp/i })).toBeVisible({