diff --git a/public/data/contributors.json b/public/data/contributors.json
index 26450bd..cfcfd29 100644
--- a/public/data/contributors.json
+++ b/public/data/contributors.json
@@ -5,7 +5,7 @@
"name": "SuprexDE",
"profileId": "215af72b-24c5-63ab-f480-eec12ba2f4e5",
"profileUrl": "https://outlasttrialsstats.com/profile/215af72b-24c5-63ab-f480-eec12ba2f4e5",
- "maps": ["police-station"]
+ "maps": ["sleep-room"]
}
]
}
diff --git a/public/data/elements.json b/public/data/elements.json
index 80b7948..6c4d181 100644
--- a/public/data/elements.json
+++ b/public/data/elements.json
@@ -255,6 +255,32 @@
}
}
},
+ {
+ "id": "shuttle",
+ "name": "Shuttle Gate",
+ "category": "door",
+ "color": "#465b92",
+ "render": {
+ "kind": "shuttle",
+ "length": 28,
+ "thickness": 7
+ },
+ "description": "Shuttle gate: a row of cells split by a control console.",
+ "propsSchema": {
+ "cells": {
+ "type": "number",
+ "label": "Cells"
+ },
+ "enterable": {
+ "type": "boolean",
+ "label": "Enterable"
+ },
+ "redButton": {
+ "type": "boolean",
+ "label": "Red button"
+ }
+ }
+ },
{
"id": "objective",
"name": "Objective",
diff --git a/public/data/maps/index.json b/public/data/maps/index.json
index b24d730..5a10c19 100644
--- a/public/data/maps/index.json
+++ b/public/data/maps/index.json
@@ -105,7 +105,7 @@
"name": "Sleep Room",
"card": "https://outlasttrialsstats.com/game-assets/Poster_Program_Weekly.webp",
"progress": 0,
- "enabled": false
+ "enabled": true
}
]
}
diff --git a/public/data/maps/sleep-room/map.json b/public/data/maps/sleep-room/map.json
new file mode 100644
index 0000000..2d65daa
--- /dev/null
+++ b/public/data/maps/sleep-room/map.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "../../../schemas/map.schema.json",
+ "id": "sleep-room",
+ "meta": {
+ "name": "Sleep Room",
+ "authors": [
+ "SuprexDE"
+ ]
+ },
+ "trials": [
+ {
+ "id": "main",
+ "name": "Sleep Room",
+ "default": true
+ }
+ ]
+}
diff --git a/public/data/maps/sleep-room/trials/main.json b/public/data/maps/sleep-room/trials/main.json
new file mode 100644
index 0000000..29a357f
--- /dev/null
+++ b/public/data/maps/sleep-room/trials/main.json
@@ -0,0 +1,275 @@
+{
+ "$schema": "../../../../schemas/trial.schema.json",
+ "mapId": "sleep-room",
+ "trialId": "main",
+ "floors": [
+ {
+ "index": 0,
+ "name": "Base Floor"
+ }
+ ],
+ "filters": [],
+ "rooms": [
+ {
+ "id": "room-0001",
+ "floor": 0,
+ "zone": "standard",
+ "shape": {
+ "origin": [
+ 385,
+ 5
+ ],
+ "rect": [
+ 40,
+ 55
+ ]
+ }
+ },
+ {
+ "id": "room-0002",
+ "floor": 0,
+ "zone": "standard",
+ "shape": {
+ "origin": [
+ 145,
+ 90
+ ],
+ "rect": [
+ 65,
+ 55
+ ]
+ }
+ },
+ {
+ "id": "room-0003",
+ "floor": 0,
+ "zone": "standard",
+ "shape": {
+ "origin": [
+ 210,
+ 105
+ ],
+ "rect": [
+ 45,
+ 40
+ ]
+ }
+ },
+ {
+ "id": "room-0004",
+ "floor": 0,
+ "zone": "standard",
+ "shape": {
+ "origin": [
+ 180,
+ 150
+ ],
+ "rect": [
+ 35,
+ 35
+ ]
+ }
+ },
+ {
+ "id": "room-0005",
+ "floor": 0,
+ "zone": "standard",
+ "shape": {
+ "origin": [
+ 180,
+ 185
+ ],
+ "rect": [
+ 35,
+ 35
+ ]
+ }
+ },
+ {
+ "id": "room-0006",
+ "floor": 0,
+ "zone": "standard",
+ "shape": {
+ "origin": [
+ 180,
+ 220
+ ],
+ "rect": [
+ 35,
+ 35
+ ]
+ }
+ },
+ {
+ "id": "room-0007",
+ "floor": 0,
+ "zone": "standard",
+ "shape": {
+ "origin": [
+ 220,
+ 145
+ ],
+ "rect": [
+ 30,
+ 115
+ ]
+ }
+ },
+ {
+ "id": "room-0008",
+ "floor": 0,
+ "zone": "standard",
+ "shape": {
+ "origin": [
+ 205,
+ 260
+ ],
+ "rect": [
+ 55,
+ 55
+ ]
+ }
+ },
+ {
+ "id": "room-0009",
+ "floor": 0,
+ "zone": "standard",
+ "shape": {
+ "origin": [
+ 385,
+ 115
+ ],
+ "rect": [
+ 35,
+ 145
+ ]
+ }
+ },
+ {
+ "id": "room-0010",
+ "floor": 0,
+ "zone": "standard",
+ "shape": {
+ "origin": [
+ 420,
+ 115
+ ],
+ "rect": [
+ 35,
+ 35
+ ]
+ }
+ },
+ {
+ "id": "room-0011",
+ "floor": 0,
+ "zone": "standard",
+ "shape": {
+ "origin": [
+ 420,
+ 150
+ ],
+ "rect": [
+ 35,
+ 35
+ ]
+ }
+ },
+ {
+ "id": "room-0012",
+ "floor": 0,
+ "zone": "standard",
+ "shape": {
+ "origin": [
+ 420,
+ 185
+ ],
+ "rect": [
+ 35,
+ 35
+ ]
+ }
+ },
+ {
+ "id": "room-0013",
+ "floor": 0,
+ "zone": "standard",
+ "shape": {
+ "origin": [
+ 420,
+ 220
+ ],
+ "rect": [
+ 35,
+ 35
+ ]
+ }
+ },
+ {
+ "id": "room-0014",
+ "floor": 0,
+ "zone": "standard",
+ "shape": {
+ "origin": [
+ 305,
+ 280
+ ],
+ "rect": [
+ 25,
+ 45
+ ]
+ }
+ },
+ {
+ "id": "room-0015",
+ "floor": 0,
+ "zone": "standard",
+ "shape": {
+ "origin": [
+ 280,
+ 110
+ ],
+ "path": "h65 v40 l5,15 l10,5 l25,5 v85 h-40 l-15,20 h-25 l-15,-20 h-40 v-115 h30 z"
+ }
+ },
+ {
+ "id": "room-0016",
+ "floor": 0,
+ "zone": "standard",
+ "shape": {
+ "origin": [
+ 390,
+ 60
+ ],
+ "path": "h30 v55 h-35 v-20 h5 z"
+ }
+ },
+ {
+ "id": "room-0017",
+ "floor": 0,
+ "zone": "standard",
+ "shape": {
+ "origin": [
+ 390,
+ 260
+ ],
+ "path": "h55 v5 h5 v35 h-5 v10 h-25 z"
+ }
+ },
+ {
+ "id": "room-0018",
+ "floor": 0,
+ "zone": "standard",
+ "shape": {
+ "origin": [
+ 290,
+ 325
+ ],
+ "path": "h55 v25 h-5 v5 h-40 v-5 h-10 z"
+ }
+ }
+ ],
+ "placements": [],
+ "routes": [],
+ "shapes": []
+}
diff --git a/public/schemas/elements.schema.json b/public/schemas/elements.schema.json
index b99c230..d15a518 100644
--- a/public/schemas/elements.schema.json
+++ b/public/schemas/elements.schema.json
@@ -69,7 +69,8 @@
"crawl-passage",
"obstacle",
"spawn-room",
- "stairs"
+ "stairs",
+ "shuttle"
]
},
"length": { "type": "number", "exclusiveMinimum": 0 },
diff --git a/src/core/constants.ts b/src/core/constants.ts
index 984dc0c..fbf6d83 100644
--- a/src/core/constants.ts
+++ b/src/core/constants.ts
@@ -165,6 +165,15 @@ export const CRAWL_BAR_SPACING = 1.6
export const STAIRS_RUNG_SPACING = 3
/** Spacing of the teeth on obstacles. */
export const OBSTACLE_TOOTH_SPACING = 2.5
+// Shuttle gate: ratios relative to the cell height (thickness = cell + 2 rails).
+export const SHUTTLE_RAIL_RATIO = 0.2
+export const SHUTTLE_WALL_RATIO = 0.4
+export const SHUTTLE_CONSOLE_RATIO = 0.75
+/** Radius of the enterable dot; clamped to half the cell width. */
+export const SHUTTLE_DOT_RATIO = 0.4
+export const SHUTTLE_BUTTON_BOX_RATIO = 0.5
+export const SHUTTLE_BUTTON_RADIUS_RATIO = 0.1
+export const SHUTTLE_DEFAULT_CELLS = 4
// Number marker (dot → leader line → diamond badge with a number)
export const MARKER_COLOR = '#aaaaaa'
diff --git a/src/core/model/types.ts b/src/core/model/types.ts
index 1ccecf2..2564e5a 100644
--- a/src/core/model/types.ts
+++ b/src/core/model/types.ts
@@ -299,6 +299,7 @@ export type StructuralKind =
| 'obstacle'
| 'spawn-room'
| 'stairs'
+ | 'shuttle'
export interface StructuralRender {
kind: StructuralKind
diff --git a/src/core/render/StructuralMarker.vue b/src/core/render/StructuralMarker.vue
index c3b2c23..dd76898 100644
--- a/src/core/render/StructuralMarker.vue
+++ b/src/core/render/StructuralMarker.vue
@@ -5,6 +5,7 @@ import {
ROOM_WALL_WIDTH,
SELECTION_COLOR,
SELECTION_RING_OFFSET,
+ SHUTTLE_DEFAULT_CELLS,
} from '../constants'
import { elementIconUrl } from '../model/dataSource'
import type { ElementDefinition, Placement } from '../model/types'
@@ -18,6 +19,11 @@ import {
obstacleChevronsPath,
obstacleTeethPath,
placementTransform,
+ shuttleButtonBoxPath,
+ shuttleButtonPath,
+ shuttleDotsPath,
+ shuttleFramePath,
+ shuttleSeamsPath,
spawnRoomFloorPath,
spawnRoomWallPath,
stairsArrowPath,
@@ -54,6 +60,14 @@ const dims = computed(() => {
const rectPath = computed(() => centeredRectPath(dims.value.length, dims.value.thickness))
const ascending = computed(() => props.placement.props?.direction !== 'down')
+const shuttleCells = computed(() => {
+ const raw = Number(props.placement.props?.cells)
+ return Number.isFinite(raw) ? Math.max(1, Math.floor(raw)) : SHUTTLE_DEFAULT_CELLS
+})
+/** Unchecked booleans are absent from `props`, so strict comparison is exact. */
+const shuttleEnterable = computed(() => props.placement.props?.enterable === true)
+const shuttleRedButton = computed(() => props.placement.props?.redButton === true)
+
/** Door/window/stairs variants differ only by the library color, not by code. */
const bodyFill = computed(() =>
meta.value?.fill === 'element' ? props.element.color : undefined,
@@ -118,6 +132,26 @@ const selectionBounds = computed(() => {
+
+
+
+
+
+
+
+
+
+
{
stroke-linejoin: round;
}
+.shuttle-body {
+ fill: #292a29;
+ stroke: #000000;
+ stroke-width: 0.6;
+}
+
+.shuttle-frame {
+ fill: #162623;
+}
+
+.shuttle-seams {
+ fill: none;
+ stroke: #162623;
+ stroke-width: 0.3;
+}
+
+.shuttle-dots {
+ fill: #465b92;
+}
+
+.shuttle-button-box {
+ fill: #666666;
+}
+
+.shuttle-button {
+ fill: #bb0000;
+ stroke: #000000;
+ stroke-width: 0.25;
+}
+
.spawn-floor {
fill: #3d3d3d;
}
diff --git a/src/core/render/structuralShapes.ts b/src/core/render/structuralShapes.ts
index aeb36a0..904505b 100644
--- a/src/core/render/structuralShapes.ts
+++ b/src/core/render/structuralShapes.ts
@@ -5,6 +5,12 @@ import {
BARRICADE_PLANK_THICKNESS,
CRAWL_BAR_SPACING,
OBSTACLE_TOOTH_SPACING,
+ SHUTTLE_BUTTON_BOX_RATIO,
+ SHUTTLE_BUTTON_RADIUS_RATIO,
+ SHUTTLE_CONSOLE_RATIO,
+ SHUTTLE_DOT_RATIO,
+ SHUTTLE_RAIL_RATIO,
+ SHUTTLE_WALL_RATIO,
STAIRS_RUNG_SPACING,
} from '../constants'
import type { Placement, StructuralKind } from '../model/types'
@@ -20,7 +26,8 @@ import type { Placement, StructuralKind } from '../model/types'
* `anchor: 'edge'` anchors the shape at the anchor edge (y=0) instead of centered;
* `fill: 'element'` fills the body with `ElementDefinition.color`, so door/window
* variants differ by data alone. `spawn-room` keeps a neutral floor because its
- * color is the enemy category accent.
+ * color is the enemy category accent; `shuttle` renders in a fixed palette and
+ * uses its color only as accent.
*/
export const STRUCTURAL_META: Record<
StructuralKind,
@@ -34,6 +41,7 @@ export const STRUCTURAL_META: Record<
obstacle: { resizable: true, anchor: 'center', fill: 'element' },
stairs: { resizable: true, anchor: 'center', fill: 'element' },
'spawn-room': { resizable: false, anchor: 'edge', fill: 'neutral' },
+ shuttle: { resizable: true, anchor: 'center', fill: 'neutral' },
}
export function placementTransform(placement: Placement): string {
@@ -140,3 +148,106 @@ export function spawnRoomFloorPath(width: number, depth: number): string {
export function spawnRoomWallPath(width: number, depth: number): string {
return `M${-width / 2},0 v${-depth} h${width} v${depth}`
}
+
+interface ShuttleGeometry {
+ cellHeight: number
+ railHeight: number
+ wallWidth: number
+ consoleWidth: number
+ cellWidth: number
+ leftCells: number
+ consoleLeft: number
+}
+
+/** Shuttle gate: cells split by a middle console, capped by side walls and rails. */
+function shuttleGeometry(length: number, thickness: number, cells: number): ShuttleGeometry {
+ const cellHeight = thickness / (1 + 2 * SHUTTLE_RAIL_RATIO)
+ const railHeight = cellHeight * SHUTTLE_RAIL_RATIO
+ const wallWidth = cellHeight * SHUTTLE_WALL_RATIO
+ const consoleWidth = cellHeight * SHUTTLE_CONSOLE_RATIO
+ const cellWidth = Math.max((length - 2 * wallWidth - consoleWidth) / cells, 0)
+ const leftCells = Math.ceil(cells / 2)
+ const consoleLeft = -length / 2 + wallWidth + leftCells * cellWidth
+ return { cellHeight, railHeight, wallWidth, consoleWidth, cellWidth, leftCells, consoleLeft }
+}
+
+/** Cells right of the console shift by its width. */
+function shuttleCellCenter(geometry: ShuttleGeometry, length: number, index: number): number {
+ const consoleOffset = index >= geometry.leftCells ? geometry.consoleWidth : 0
+ return -length / 2 + geometry.wallWidth + (index + 0.5) * geometry.cellWidth + consoleOffset
+}
+
+function rectSubpath(x: number, y: number, width: number, height: number): string {
+ return `M${x},${y} h${width} v${height} h${-width} z`
+}
+
+function circlePath(cx: number, cy: number, r: number): string {
+ return `M${cx - r},${cy} a${r},${r} 0 1,0 ${2 * r},0 a${r},${r} 0 1,0 ${-2 * r},0 z`
+}
+
+/** Side walls, top/bottom rails and the console column, one layer. */
+export function shuttleFramePath(length: number, thickness: number, cells: number): string {
+ const geometry = shuttleGeometry(length, thickness, cells)
+ const top = -thickness / 2
+ const innerWidth = Math.max(length - 2 * geometry.wallWidth, 0)
+ return [
+ rectSubpath(-length / 2, top, geometry.wallWidth, thickness),
+ rectSubpath(length / 2 - geometry.wallWidth, top, geometry.wallWidth, thickness),
+ rectSubpath(-length / 2 + geometry.wallWidth, top, innerWidth, geometry.railHeight),
+ rectSubpath(
+ -length / 2 + geometry.wallWidth,
+ thickness / 2 - geometry.railHeight,
+ innerWidth,
+ geometry.railHeight,
+ ),
+ rectSubpath(geometry.consoleLeft, top, geometry.consoleWidth, thickness),
+ ].join(' ')
+}
+
+/** Vertical seams between adjacent cells inside each group (console boundary excluded). */
+export function shuttleSeamsPath(length: number, thickness: number, cells: number): string {
+ const geometry = shuttleGeometry(length, thickness, cells)
+ if (geometry.cellWidth <= 0) {
+ return ''
+ }
+ const segments: string[] = []
+ for (let index = 1; index < cells; index += 1) {
+ if (index === geometry.leftCells) {
+ continue
+ }
+ const x = shuttleCellCenter(geometry, length, index) - geometry.cellWidth / 2
+ segments.push(`M${x},${-geometry.cellHeight / 2} v${geometry.cellHeight}`)
+ }
+ return segments.join(' ')
+}
+
+/** One dot per cell, shown when the shuttle is enterable. */
+export function shuttleDotsPath(length: number, thickness: number, cells: number): string {
+ const geometry = shuttleGeometry(length, thickness, cells)
+ const radius = Math.min(SHUTTLE_DOT_RATIO * geometry.cellHeight, geometry.cellWidth / 2)
+ if (radius <= 0) {
+ return ''
+ }
+ return Array.from({ length: cells }, (_, index) =>
+ circlePath(shuttleCellCenter(geometry, length, index), 0, radius),
+ ).join(' ')
+}
+
+/** Control box protruding in front of the gate, flush with the console column. */
+export function shuttleButtonBoxPath(length: number, thickness: number, cells: number): string {
+ const geometry = shuttleGeometry(length, thickness, cells)
+ const boxHeight = geometry.cellHeight * SHUTTLE_BUTTON_BOX_RATIO
+ return rectSubpath(geometry.consoleLeft, -thickness / 2 - boxHeight, geometry.consoleWidth, boxHeight)
+}
+
+/** Red button in the center of the control box. */
+export function shuttleButtonPath(length: number, thickness: number, cells: number): string {
+ const geometry = shuttleGeometry(length, thickness, cells)
+ const boxHeight = geometry.cellHeight * SHUTTLE_BUTTON_BOX_RATIO
+ const radius = geometry.cellHeight * SHUTTLE_BUTTON_RADIUS_RATIO
+ return circlePath(
+ geometry.consoleLeft + geometry.consoleWidth / 2,
+ -thickness / 2 - boxHeight / 2,
+ radius,
+ )
+}