Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,6 @@
],
"rooms": [],
"placements": [],
"routes": []
"routes": [],
"shapes": []
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,6 @@
],
"rooms": [],
"placements": [],
"routes": []
"routes": [],
"shapes": []
}
3 changes: 2 additions & 1 deletion public/data/maps/police-station/trials/kill-the-snitch.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,6 @@
],
"rooms": [],
"placements": [],
"routes": []
"routes": [],
"shapes": []
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,6 @@
],
"rooms": [],
"placements": [],
"routes": []
"routes": [],
"shapes": []
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,6 @@
],
"rooms": [],
"placements": [],
"routes": []
"routes": [],
"shapes": []
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,6 @@
],
"rooms": [],
"placements": [],
"routes": []
"routes": [],
"shapes": []
}
34 changes: 33 additions & 1 deletion public/schemas/trial.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"$id": "https://maps.outlasttrialsstats.com/schemas/trial.schema.json",
"title": "Trial-Definition",
"type": "object",
"required": ["mapId", "trialId", "floors", "filters", "rooms", "placements", "routes"],
"required": ["mapId", "trialId", "floors", "filters", "rooms", "placements", "routes", "shapes"],
"additionalProperties": false,
"properties": {
"$schema": { "type": "string" },
Expand Down Expand Up @@ -51,6 +51,10 @@
"routes": {
"type": "array",
"items": { "$ref": "#/definitions/route" }
},
"shapes": {
"type": "array",
"items": { "$ref": "#/definitions/shape" }
}
},
"definitions": {
Expand Down Expand Up @@ -205,6 +209,34 @@
"props": { "type": "object" }
}
},
"shape": {
"description": "Free-standing decorative outline (circle, rectangle or open polyline); color/strokeWidth/dashed override the render defaults.",
"type": "object",
"required": ["id", "floor"],
"additionalProperties": false,
"oneOf": [
{ "required": ["pos", "radius"] },
{ "required": ["pos", "size"] },
{ "required": ["path"] }
],
"properties": {
"id": { "$ref": "#/definitions/kebabId" },
"floor": { "type": "integer" },
"color": { "$ref": "#/definitions/color" },
"strokeWidth": { "type": "number", "exclusiveMinimum": 0 },
"dashed": { "type": "boolean" },
"pos": { "$ref": "#/definitions/vec2" },
"radius": { "type": "number", "exclusiveMinimum": 0 },
"size": {
"type": "array",
"items": { "type": "number", "exclusiveMinimum": 0 },
"minItems": 2,
"maxItems": 2
},
"rotation": { "type": "number", "multipleOf": 45 },
"path": { "$ref": "#/definitions/svgPath" }
}
},
"route": {
"type": "object",
"required": ["id", "name", "floor", "path", "style"],
Expand Down
4 changes: 4 additions & 0 deletions src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ export const DISABLED_ROOM_OPACITY = 0.45
export const FALLBACK_ZONE_FILL = '#4a4a4a'
export const FALLBACK_ZONE_WALLS = '#111111'
export const DEFAULT_LABEL_FONT_SIZE = 8
/** Default outline of free-standing shapes; per-shape overrides are optional. */
export const SHAPE_DEFAULT_COLOR = '#85858c'
export const SHAPE_DEFAULT_STROKE_WIDTH = 1
export const SHAPE_LINE_DASH = '3 2'
export const UNKNOWN_ELEMENT_COLOR = '#7f8c8d'
/** Font size of the placeholder initials relative to the icon size. */
export const PLACEHOLDER_FONT_RATIO = 0.45
Expand Down
9 changes: 5 additions & 4 deletions src/core/interaction/hitTest.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
export type EntityKind = 'room' | 'placement' | 'route'
const ENTITY_KINDS = ['room', 'placement', 'route', 'shape'] as const

export type EntityKind = (typeof ENTITY_KINDS)[number]

export interface HitTarget {
kind: EntityKind
id: string
}

const ENTITY_KINDS: readonly string[] = ['room', 'placement', 'route']

/**
* Determines the hit map object via event delegation: the render components
* mark their root group with `data-entity-kind` and `data-entity-id` and stay
Expand All @@ -22,7 +22,8 @@ export function hitFromEventTarget(target: EventTarget | null): HitTarget | null
}
const kind = entityEl.getAttribute('data-entity-kind')
const id = entityEl.getAttribute('data-entity-id')
if (!kind || !id || !ENTITY_KINDS.includes(kind)) {
const kinds: readonly string[] = ENTITY_KINDS
if (!kind || !id || !kinds.includes(kind)) {
return null
}
return { kind: kind as EntityKind, id }
Expand Down
10 changes: 10 additions & 0 deletions src/core/model/roomPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,16 @@ export function parseOpenPath(path: string): Vec2[] | null {
return parsed.points
}

/** Points of an open path (routes, shapes); unparsable paths fall back to their start point. */
export function openPathPoints(path: string): Vec2[] {
const points = parseOpenPath(path)
if (points) {
return points
}
const start = absolutePathStart(path)
return start ? [start] : []
}

function relativeSegments(points: Vec2[]): string[] {
const parts: string[] = []
for (let i = 1; i < points.length; i += 1) {
Expand Down
53 changes: 53 additions & 0 deletions src/core/model/shapes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { openPathPoints, translateAbsolutePathStart } from './roomPath'
import type { MapShape, Vec2 } from './types'

export function isCircleShape(shape: MapShape): shape is Extract<MapShape, { radius: number }> {
return 'radius' in shape
}

export function isRectShape(shape: MapShape): shape is Extract<MapShape, { size: Vec2 }> {
return 'size' in shape
}

export function isLineShape(shape: MapShape): shape is Extract<MapShape, { path: string }> {
return 'path' in shape
}

/**
* Characteristic points in world coordinates: circle extremes, rotated rect
* corners, polyline vertices.
*/
export function shapeWorldPoints(shape: MapShape): Vec2[] {
if (isCircleShape(shape)) {
const [x, y] = shape.pos
const r = shape.radius
return [
[x - r, y],
[x + r, y],
[x, y - r],
[x, y + r],
]
}
if (isRectShape(shape)) {
const [cx, cy] = shape.pos
const [hw, hh] = [shape.size[0] / 2, shape.size[1] / 2]
const angle = ((shape.rotation ?? 0) * Math.PI) / 180
const [cos, sin] = [Math.cos(angle), Math.sin(angle)]
const corners: Vec2[] = [
[-hw, -hh],
[hw, -hh],
[hw, hh],
[-hw, hh],
]
return corners.map(([x, y]): Vec2 => [cx + x * cos - y * sin, cy + x * sin + y * cos])
}
return openPathPoints(shape.path)
}

export function translateShape(shape: MapShape, delta: Vec2): void {
if (isLineShape(shape)) {
shape.path = translateAbsolutePathStart(shape.path, delta) ?? shape.path
return
}
shape.pos = [shape.pos[0] + delta[0], shape.pos[1] + delta[1]]
}
25 changes: 25 additions & 0 deletions src/core/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export interface TrialDocument {
rooms: Room[]
placements: Placement[]
routes: RouteLine[]
shapes: MapShape[]
}

export interface MapMeta {
Expand Down Expand Up @@ -221,6 +222,30 @@ export interface RouteLine {
style: RouteLineStyle
}

// ---------------------------------------------------------------------------
// Shapes
// ---------------------------------------------------------------------------

/**
* Free-standing decorative outline (tables, scaffolding …) — neither a room
* nor a library element, never filled. Variants: circle (`pos` = center),
* rectangle (`pos` = center, rotation in 45° steps in sync with the schema
* `multipleOf`) or an open absolute path.
*/
export type MapShapeGeometry =
| { pos: Vec2; radius: number }
| { pos: Vec2; size: Vec2; rotation?: number }
| { path: string }

export type MapShape = {
id: string
floor: number
/** Stroke overrides — `SHAPE_DEFAULT_*` constants apply and are not stored. */
color?: string
strokeWidth?: number
dashed?: boolean
} & MapShapeGeometry

// ---------------------------------------------------------------------------
// Zone library — public/data/zones.json (global across all maps)
// ---------------------------------------------------------------------------
Expand Down
17 changes: 12 additions & 5 deletions src/core/model/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ export function collectTrialLogicIssues(
['rooms', 'room', trial.rooms.map((room) => room.id)],
['placements', 'placement', trial.placements.map((placement) => placement.id)],
['routes', 'route', trial.routes.map((route) => route.id)],
['shapes', 'shape', trial.shapes.map((shape) => shape.id)],
]
for (const [path, label, ids] of uniqueIdChecks) {
checkUniqueIds(issues, path, label, ids)
Expand All @@ -277,11 +278,17 @@ export function collectTrialLogicIssues(
trial.placements.forEach((placement, index) =>
issues.push(...collectPlacementIssues(placement, index, context)),
)
trial.routes.forEach((route, index) => {
if (!context.floorIndexes.has(route.floor)) {
issues.push({ path: `routes[${index}].floor`, message: `unknown floor ${route.floor}` })
}
})
const floorChecks: Array<[string, Array<{ floor: number }>]> = [
['routes', trial.routes],
['shapes', trial.shapes],
]
for (const [path, items] of floorChecks) {
items.forEach((item, index) => {
if (!context.floorIndexes.has(item.floor)) {
issues.push({ path: `${path}[${index}].floor`, message: `unknown floor ${item.floor}` })
}
})
}
trial.filters.forEach((filter, filterIndex) => {
filter.categories.forEach((category) => {
if (!context.categoryIds.has(category)) {
Expand Down
15 changes: 12 additions & 3 deletions src/core/render/FloorLayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import CalloutMarker from './CalloutMarker.vue'
import PlacementMarker from './PlacementMarker.vue'
import RoomShape from './RoomShape.vue'
import RoutePath from './RoutePath.vue'
import ShapeOutline from './ShapeOutline.vue'

const props = defineProps<{
trial: TrialDocument
Expand All @@ -14,8 +15,8 @@ const props = defineProps<{
zones: ReadonlyMap<string, Zone>
selectedIds?: ReadonlySet<string>
hiddenCategories?: ReadonlySet<string>
/** Editor: routes get an invisible wide hit stroke for clicking/double-clicking. */
interactiveRoutes?: boolean
/** Editor: routes and shapes get an invisible wide hit stroke along their outline. */
interactive?: boolean
}>()

function visible<T extends { floor: number }>(items: T[]): T[] {
Expand All @@ -30,6 +31,7 @@ const placements = computed(() =>
}),
)
const routes = computed(() => visible(props.trial.routes))
const shapes = computed(() => visible(props.trial.shapes))
/** Drawn after all placements so callouts never disappear behind a neighbour. */
const markedPlacements = computed(() => placements.value.filter((placement) => placement.marker))
</script>
Expand All @@ -43,12 +45,19 @@ const markedPlacements = computed(() => placements.value.filter((placement) => p
:zone="zones.get(room.zone)"
:selected="selectedIds?.has(room.id)"
/>
<ShapeOutline
v-for="shape in shapes"
:key="shape.id"
:shape="shape"
:selected="selectedIds?.has(shape.id)"
:hit-area="interactive"
/>
<RoutePath
v-for="route in routes"
:key="route.id"
:route="route"
:selected="selectedIds?.has(route.id)"
:hit-area="interactiveRoutes"
:hit-area="interactive"
/>
<PlacementMarker
v-for="placement in placements"
Expand Down
Loading