zones as nodes

This commit is contained in:
wass08
2026-01-26 07:34:53 +09:00
parent e49ba32d18
commit fb3cd3e34e
13 changed files with 192 additions and 349 deletions
+40 -50
View File
@@ -1,74 +1,64 @@
import type { ThreeEvent } from "@react-three/fiber";
import mitt from "mitt";
import type { BuildingNode, ItemNode, WallNode } from "../schema";
import type { AnyNode } from "../schema/types";
import type { Zone } from "../schema/zone";
import type { ThreeEvent } from '@react-three/fiber'
import mitt from 'mitt'
import type { BuildingNode, ItemNode, WallNode, ZoneNode } from '../schema'
import type { AnyNode } from '../schema/types'
// Base event interfaces
export interface GridEvent {
position: [number, number, number];
nativeEvent: ThreeEvent<PointerEvent>;
position: [number, number, number]
nativeEvent: ThreeEvent<PointerEvent>
}
export interface NodeEvent<T extends AnyNode = AnyNode> {
node: T;
position: [number, number, number];
localPosition: [number, number, number];
normal?: [number, number, number];
stopPropagation: () => void;
nativeEvent: ThreeEvent<PointerEvent>;
node: T
position: [number, number, number]
localPosition: [number, number, number]
normal?: [number, number, number]
stopPropagation: () => void
nativeEvent: ThreeEvent<PointerEvent>
}
export interface ZoneEvent {
zone: Zone;
position: [number, number, number];
stopPropagation: () => void;
nativeEvent: ThreeEvent<PointerEvent>;
}
export type WallEvent = NodeEvent<WallNode>;
export type ItemEvent = NodeEvent<ItemNode>;
export type BuildingEvent = NodeEvent<BuildingNode>;
export type WallEvent = NodeEvent<WallNode>
export type ItemEvent = NodeEvent<ItemNode>
export type BuildingEvent = NodeEvent<BuildingNode>
export type ZoneEvent = NodeEvent<ZoneNode>
// Event suffixes - exported for use in hooks
export const eventSuffixes = [
"click",
"move",
"enter",
"leave",
"pointerdown",
"pointerup",
"context-menu",
"double-click",
] as const;
'click',
'move',
'enter',
'leave',
'pointerdown',
'pointerup',
'context-menu',
'double-click',
] as const
export type EventSuffix = (typeof eventSuffixes)[number];
export type EventSuffix = (typeof eventSuffixes)[number]
type NodeEvents<T extends string, E> = {
[K in `${T}:${EventSuffix}`]: E;
};
[K in `${T}:${EventSuffix}`]: E
}
type GridEvents = {
[K in `grid:${EventSuffix}`]: GridEvent;
};
[K in `grid:${EventSuffix}`]: GridEvent
}
export interface CameraControlEvent {
nodeId: AnyNode["id"];
nodeId: AnyNode['id']
}
type CameraControlEvents = {
"camera-controls:view": CameraControlEvent;
"camera-controls:capture": CameraControlEvent;
};
type ZoneEvents = {
[K in `zone:${EventSuffix}`]: ZoneEvent;
};
'camera-controls:view': CameraControlEvent
'camera-controls:capture': CameraControlEvent
}
type EditorEvents = GridEvents &
NodeEvents<"wall", WallEvent> &
NodeEvents<"item", ItemEvent> &
NodeEvents<"building", BuildingEvent> &
ZoneEvents &
CameraControlEvents;
NodeEvents<'wall', WallEvent> &
NodeEvents<'item', ItemEvent> &
NodeEvents<'building', BuildingEvent> &
NodeEvents<'zone', ZoneEvent> &
CameraControlEvents
export const emitter = mitt<EditorEvents>();
export const emitter = mitt<EditorEvents>()
+2 -1
View File
@@ -1,6 +1,7 @@
// Store
export type {
BuildingEvent,
CameraControlEvent,
EventSuffix,
GridEvent,
@@ -27,4 +28,4 @@ export { default as useScene } from './store/use-scene'
// Systems
export { WallSystem } from './systems/wall/wall-system'
export { isObject } from './utils/types'
export { isObject } from './utils/types'
+2 -4
View File
@@ -9,10 +9,8 @@ export { LevelNode } from './nodes/level'
// Nodes
export { SiteNode } from './nodes/site'
export { WallNode } from './nodes/wall'
// Zones
export type { ZoneNode, ZonePolygon } from './nodes/zone'
export type { AnyNodeId, AnyNodeType } from './types'
// Union types
export { AnyNode } from './types'
// Zones
export type { Zone, ZonePolygon } from './zone'
export { ZoneSchema } from './zone'
+3 -1
View File
@@ -2,11 +2,13 @@ import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { WallNode } from './wall'
import { ZoneNode } from './zone'
export const LevelNode = BaseNode.extend({
id: objectId('level'),
type: nodeType('level'),
children: z.array(WallNode.shape.id).default([]),
children: z.array(z.union([WallNode.shape.id, ZoneNode.shape.id])).default([]),
// Specific props
level: z.number().default(0),
}).describe(
+31
View File
@@ -0,0 +1,31 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
// Polygon boundary for zone area - array of [x, z] coordinates
export const ZonePolygon = z.array(z.tuple([z.number(), z.number()]))
export const ZoneNode = BaseNode.extend({
id: objectId('zone'),
type: nodeType('zone'),
name: z.string(),
// Polygon boundary - array of [x, z] coordinates defining the zone
polygon: ZonePolygon,
// Visual styling
color: z.string().default('#3b82f6'), // Default blue
metadata: z.json().optional().default({}),
}).describe(
dedent`
Zone schema - a polygon zone attached to a level
- object: "zone"
- id: zone id
- levelId: level this zone is attached to
- name: zone name
- polygon: array of [x, z] points defining the zone boundary
- color: hex color for visual styling
- metadata: zone metadata (optional)
`,
)
export type ZoneNode = z.infer<typeof ZoneNode>
export type ZonePolygon = z.infer<typeof ZonePolygon>
+2
View File
@@ -4,6 +4,7 @@ import { ItemNode } from './nodes/item'
import { LevelNode } from './nodes/level'
import { SiteNode } from './nodes/site'
import { WallNode } from './nodes/wall'
import { ZoneNode } from './nodes/zone'
export const AnyNode = z.discriminatedUnion('type', [
SiteNode,
@@ -11,6 +12,7 @@ export const AnyNode = z.discriminatedUnion('type', [
LevelNode,
WallNode,
ItemNode,
ZoneNode,
])
export type AnyNode = z.infer<typeof AnyNode>
-35
View File
@@ -1,35 +0,0 @@
import dedent from 'dedent'
import { z } from 'zod'
import { objectId } from './base'
import { LevelNode } from './nodes/level'
// Polygon boundary for zone area - array of [x, z] coordinates
export const ZonePolygon = z.array(z.tuple([z.number(), z.number()]))
export const ZoneSchema = z
.object({
id: objectId('zone'),
object: z.literal('zone').default('zone'),
levelId: LevelNode.shape.id, // Required - must be attached to a level
name: z.string(),
// Polygon boundary - array of [x, z] coordinates defining the zone
polygon: ZonePolygon,
// Visual styling
color: z.string().default('#3b82f6'), // Default blue
metadata: z.json().optional().default({}),
})
.describe(
dedent`
Zone schema - a polygon zone attached to a level
- object: "zone"
- id: zone id
- levelId: level this zone is attached to
- name: zone name
- polygon: array of [x, z] points defining the zone boundary
- color: hex color for visual styling
- metadata: zone metadata (optional)
`,
)
export type Zone = z.infer<typeof ZoneSchema>
export type ZonePolygon = z.infer<typeof ZonePolygon>
@@ -1,60 +0,0 @@
import type { Zone } from '../../schema'
import type { SceneState } from '../use-scene'
export const createZonesAction = (
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
_get: () => SceneState,
zones: Zone[],
) => {
set((state) => {
const nextZones = { ...state.zones }
const nextZoneIds = [...state.zoneIds]
for (const zone of zones) {
nextZones[zone.id] = zone
if (!nextZoneIds.includes(zone.id)) {
nextZoneIds.push(zone.id)
}
}
return { zones: nextZones, zoneIds: nextZoneIds }
})
}
export const updateZonesAction = (
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
_get: () => SceneState,
updates: { id: Zone['id']; data: Partial<Zone> }[],
) => {
set((state) => {
const nextZones = { ...state.zones }
for (const { id, data } of updates) {
const currentZone = nextZones[id]
if (!currentZone) continue
nextZones[id] = { ...currentZone, ...data }
}
return { zones: nextZones }
})
}
export const deleteZonesAction = (
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
_get: () => SceneState,
ids: Zone['id'][],
) => {
set((state) => {
const nextZones = { ...state.zones }
let nextZoneIds = [...state.zoneIds]
for (const id of ids) {
delete nextZones[id]
nextZoneIds = nextZoneIds.filter((zid) => zid !== id)
}
return { zones: nextZones, zoneIds: nextZoneIds }
})
}
+1 -30
View File
@@ -3,21 +3,18 @@
import { temporal } from 'zundo'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { BuildingNode, type Zone } from '../schema'
import { BuildingNode } from '../schema'
import { LevelNode } from '../schema/nodes/level'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { isObject } from '../utils/types'
import * as nodeActions from './actions/node-actions'
import * as zoneActions from './actions/zone-actions'
export type SceneState = {
// 1. The Data: A flat dictionary of all nodes
nodes: Record<AnyNodeId, AnyNode>
zones: Record<Zone['id'], Zone>
// 2. The Root: Which nodes are at the top level?
rootNodeIds: AnyNodeId[]
zoneIds: Zone['id'][]
// 3. The "Dirty" Set: For the Wall/Physics systems
dirtyNodes: Set<AnyNodeId>
@@ -37,14 +34,6 @@ export type SceneState = {
deleteNode: (id: AnyNodeId) => void
deleteNodes: (ids: AnyNodeId[]) => void
// Zone actions
createZone: (zone: Zone) => void
createZones: (zones: Zone[]) => void
updateZone: (id: Zone['id'], data: Partial<Zone>) => void
updateZones: (updates: { id: Zone['id']; data: Partial<Zone> }[]) => void
deleteZone: (id: Zone['id']) => void
deleteZones: (ids: Zone['id'][]) => void
}
// type PartializedStoreState = Pick<SceneState, 'rootNodeIds' | 'nodes'>;
@@ -55,11 +44,9 @@ const useScene = create<SceneState>()(
(set, get) => ({
// 1. Flat dictionary of all nodes
nodes: {},
zones: {},
// 2. Root node IDs
rootNodeIds: [],
zoneIds: [],
// 3. Dirty set
dirtyNodes: new Set<AnyNodeId>(),
@@ -68,8 +55,6 @@ const useScene = create<SceneState>()(
set({
nodes: {},
rootNodeIds: [],
zones: {},
zoneIds: [],
dirtyNodes: new Set<AnyNodeId>(),
})
get().loadScene() // Default scene
@@ -127,17 +112,6 @@ const useScene = create<SceneState>()(
deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids),
deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]),
// --- ZONES ---
createZones: (zones) => zoneActions.createZonesAction(set, get, zones),
createZone: (zone) => zoneActions.createZonesAction(set, get, [zone]),
updateZones: (updates) => zoneActions.updateZonesAction(set, get, updates),
updateZone: (id, data) => zoneActions.updateZonesAction(set, get, [{ id, data }]),
deleteZones: (ids) => zoneActions.deleteZonesAction(set, get, ids),
deleteZone: (id) => zoneActions.deleteZonesAction(set, get, [id]),
}),
{
partialize: (state) => {
@@ -159,11 +133,8 @@ const useScene = create<SceneState>()(
}),
),
rootNodeIds: state.rootNodeIds,
zones: state.zones,
zoneIds: state.zoneIds,
}),
onRehydrateStorage: (state) => {
console.log('hydrating...')
// optional