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 type { ThreeEvent } from '@react-three/fiber'
import mitt from "mitt"; import mitt from 'mitt'
import type { BuildingNode, ItemNode, WallNode } from "../schema"; import type { BuildingNode, ItemNode, WallNode, ZoneNode } from '../schema'
import type { AnyNode } from "../schema/types"; import type { AnyNode } from '../schema/types'
import type { Zone } from "../schema/zone";
// Base event interfaces // Base event interfaces
export interface GridEvent { export interface GridEvent {
position: [number, number, number]; position: [number, number, number]
nativeEvent: ThreeEvent<PointerEvent>; nativeEvent: ThreeEvent<PointerEvent>
} }
export interface NodeEvent<T extends AnyNode = AnyNode> { export interface NodeEvent<T extends AnyNode = AnyNode> {
node: T; node: T
position: [number, number, number]; position: [number, number, number]
localPosition: [number, number, number]; localPosition: [number, number, number]
normal?: [number, number, number]; normal?: [number, number, number]
stopPropagation: () => void; stopPropagation: () => void
nativeEvent: ThreeEvent<PointerEvent>; nativeEvent: ThreeEvent<PointerEvent>
} }
export interface ZoneEvent { export type WallEvent = NodeEvent<WallNode>
zone: Zone; export type ItemEvent = NodeEvent<ItemNode>
position: [number, number, number]; export type BuildingEvent = NodeEvent<BuildingNode>
stopPropagation: () => void; export type ZoneEvent = NodeEvent<ZoneNode>
nativeEvent: ThreeEvent<PointerEvent>;
}
export type WallEvent = NodeEvent<WallNode>;
export type ItemEvent = NodeEvent<ItemNode>;
export type BuildingEvent = NodeEvent<BuildingNode>;
// Event suffixes - exported for use in hooks // Event suffixes - exported for use in hooks
export const eventSuffixes = [ export const eventSuffixes = [
"click", 'click',
"move", 'move',
"enter", 'enter',
"leave", 'leave',
"pointerdown", 'pointerdown',
"pointerup", 'pointerup',
"context-menu", 'context-menu',
"double-click", 'double-click',
] as const; ] as const
export type EventSuffix = (typeof eventSuffixes)[number]; export type EventSuffix = (typeof eventSuffixes)[number]
type NodeEvents<T extends string, E> = { type NodeEvents<T extends string, E> = {
[K in `${T}:${EventSuffix}`]: E; [K in `${T}:${EventSuffix}`]: E
}; }
type GridEvents = { type GridEvents = {
[K in `grid:${EventSuffix}`]: GridEvent; [K in `grid:${EventSuffix}`]: GridEvent
}; }
export interface CameraControlEvent { export interface CameraControlEvent {
nodeId: AnyNode["id"]; nodeId: AnyNode['id']
} }
type CameraControlEvents = { type CameraControlEvents = {
"camera-controls:view": CameraControlEvent; 'camera-controls:view': CameraControlEvent
"camera-controls:capture": CameraControlEvent; 'camera-controls:capture': CameraControlEvent
}; }
type ZoneEvents = {
[K in `zone:${EventSuffix}`]: ZoneEvent;
};
type EditorEvents = GridEvents & type EditorEvents = GridEvents &
NodeEvents<"wall", WallEvent> & NodeEvents<'wall', WallEvent> &
NodeEvents<"item", ItemEvent> & NodeEvents<'item', ItemEvent> &
NodeEvents<"building", BuildingEvent> & NodeEvents<'building', BuildingEvent> &
ZoneEvents & NodeEvents<'zone', ZoneEvent> &
CameraControlEvents; CameraControlEvents
export const emitter = mitt<EditorEvents>(); export const emitter = mitt<EditorEvents>()
+1
View File
@@ -1,6 +1,7 @@
// Store // Store
export type { export type {
BuildingEvent,
CameraControlEvent, CameraControlEvent,
EventSuffix, EventSuffix,
GridEvent, GridEvent,
+2 -4
View File
@@ -9,10 +9,8 @@ export { LevelNode } from './nodes/level'
// Nodes // Nodes
export { SiteNode } from './nodes/site' export { SiteNode } from './nodes/site'
export { WallNode } from './nodes/wall' export { WallNode } from './nodes/wall'
// Zones
export type { ZoneNode, ZonePolygon } from './nodes/zone'
export type { AnyNodeId, AnyNodeType } from './types' export type { AnyNodeId, AnyNodeType } from './types'
// Union types // Union types
export { AnyNode } from './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 { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { WallNode } from './wall' import { WallNode } from './wall'
import { ZoneNode } from './zone'
export const LevelNode = BaseNode.extend({ export const LevelNode = BaseNode.extend({
id: objectId('level'), id: objectId('level'),
type: nodeType('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 // Specific props
level: z.number().default(0), level: z.number().default(0),
}).describe( }).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 { LevelNode } from './nodes/level'
import { SiteNode } from './nodes/site' import { SiteNode } from './nodes/site'
import { WallNode } from './nodes/wall' import { WallNode } from './nodes/wall'
import { ZoneNode } from './nodes/zone'
export const AnyNode = z.discriminatedUnion('type', [ export const AnyNode = z.discriminatedUnion('type', [
SiteNode, SiteNode,
@@ -11,6 +12,7 @@ export const AnyNode = z.discriminatedUnion('type', [
LevelNode, LevelNode,
WallNode, WallNode,
ItemNode, ItemNode,
ZoneNode,
]) ])
export type AnyNode = z.infer<typeof AnyNode> 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 { temporal } from 'zundo'
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware' import { persist } from 'zustand/middleware'
import { BuildingNode, type Zone } from '../schema' import { BuildingNode } from '../schema'
import { LevelNode } from '../schema/nodes/level' import { LevelNode } from '../schema/nodes/level'
import type { AnyNode, AnyNodeId } from '../schema/types' import type { AnyNode, AnyNodeId } from '../schema/types'
import { isObject } from '../utils/types' import { isObject } from '../utils/types'
import * as nodeActions from './actions/node-actions' import * as nodeActions from './actions/node-actions'
import * as zoneActions from './actions/zone-actions'
export type SceneState = { export type SceneState = {
// 1. The Data: A flat dictionary of all nodes // 1. The Data: A flat dictionary of all nodes
nodes: Record<AnyNodeId, AnyNode> nodes: Record<AnyNodeId, AnyNode>
zones: Record<Zone['id'], Zone>
// 2. The Root: Which nodes are at the top level? // 2. The Root: Which nodes are at the top level?
rootNodeIds: AnyNodeId[] rootNodeIds: AnyNodeId[]
zoneIds: Zone['id'][]
// 3. The "Dirty" Set: For the Wall/Physics systems // 3. The "Dirty" Set: For the Wall/Physics systems
dirtyNodes: Set<AnyNodeId> dirtyNodes: Set<AnyNodeId>
@@ -37,14 +34,6 @@ export type SceneState = {
deleteNode: (id: AnyNodeId) => void deleteNode: (id: AnyNodeId) => void
deleteNodes: (ids: 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'>; // type PartializedStoreState = Pick<SceneState, 'rootNodeIds' | 'nodes'>;
@@ -55,11 +44,9 @@ const useScene = create<SceneState>()(
(set, get) => ({ (set, get) => ({
// 1. Flat dictionary of all nodes // 1. Flat dictionary of all nodes
nodes: {}, nodes: {},
zones: {},
// 2. Root node IDs // 2. Root node IDs
rootNodeIds: [], rootNodeIds: [],
zoneIds: [],
// 3. Dirty set // 3. Dirty set
dirtyNodes: new Set<AnyNodeId>(), dirtyNodes: new Set<AnyNodeId>(),
@@ -68,8 +55,6 @@ const useScene = create<SceneState>()(
set({ set({
nodes: {}, nodes: {},
rootNodeIds: [], rootNodeIds: [],
zones: {},
zoneIds: [],
dirtyNodes: new Set<AnyNodeId>(), dirtyNodes: new Set<AnyNodeId>(),
}) })
get().loadScene() // Default scene get().loadScene() // Default scene
@@ -127,17 +112,6 @@ const useScene = create<SceneState>()(
deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids), deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids),
deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]), 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) => { partialize: (state) => {
@@ -159,11 +133,8 @@ const useScene = create<SceneState>()(
}), }),
), ),
rootNodeIds: state.rootNodeIds, rootNodeIds: state.rootNodeIds,
zones: state.zones,
zoneIds: state.zoneIds,
}), }),
onRehydrateStorage: (state) => { onRehydrateStorage: (state) => {
console.log('hydrating...') console.log('hydrating...')
// optional // optional
@@ -1,14 +1,12 @@
import { type LevelNode, useRegistry, useScene } from "@pascal-app/core"; import { type LevelNode, useRegistry } from '@pascal-app/core'
import { useRef } from "react"; import { useRef } from 'react'
import type { Group } from "three"; import type { Group } from 'three'
import { useShallow } from "zustand/shallow"; import { NodeRenderer } from '../node-renderer'
import { NodeRenderer } from "../node-renderer";
import { ZoneRenderer } from "../zone/zone-renderer";
export const LevelRenderer = ({ node }: { node: LevelNode }) => { export const LevelRenderer = ({ node }: { node: LevelNode }) => {
const ref = useRef<Group>(null!); const ref = useRef<Group>(null!)
useRegistry(node.id, node.type, ref); useRegistry(node.id, node.type, ref)
return ( return (
<group ref={ref}> <group ref={ref}>
@@ -19,17 +17,6 @@ export const LevelRenderer = ({ node }: { node: LevelNode }) => {
{node.children.map((childId) => ( {node.children.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} /> <NodeRenderer key={childId} nodeId={childId} />
))} ))}
<LevelZones levelId={node.id} />
</group> </group>
); )
}; }
const LevelZones = ({ levelId }: { levelId: LevelNode["id"] }) => {
const zoneIds = useScene(
useShallow((s) =>
s.zoneIds.filter((id) => s.zones[id]?.levelId === levelId),
),
);
return zoneIds.map((zoneId) => <ZoneRenderer key={zoneId} zoneId={zoneId} />);
};
@@ -5,6 +5,7 @@ import { BuildingRenderer } from './building/building-renderer'
import { ItemRenderer } from './item/item-renderer' import { ItemRenderer } from './item/item-renderer'
import { LevelRenderer } from './level/level-renderer' import { LevelRenderer } from './level/level-renderer'
import { WallRenderer } from './wall/wall-renderer' import { WallRenderer } from './wall/wall-renderer'
import { ZoneRenderer } from './zone/zone-renderer'
export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
const node = useScene((state) => state.nodes[nodeId]) const node = useScene((state) => state.nodes[nodeId])
@@ -17,6 +18,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
{node.type === 'level' && <LevelRenderer node={node} />} {node.type === 'level' && <LevelRenderer node={node} />}
{node.type === 'item' && <ItemRenderer node={node} />} {node.type === 'item' && <ItemRenderer node={node} />}
{node.type === 'wall' && <WallRenderer node={node} />} {node.type === 'wall' && <WallRenderer node={node} />}
{node.type === 'zone' && <ZoneRenderer node={node} />}
</> </>
) )
} }
@@ -1,33 +1,25 @@
import { emitter, useRegistry, useScene, type Zone } from "@pascal-app/core"; import { useRegistry, type ZoneNode } from '@pascal-app/core'
import { useMemo, useRef } from 'react'
import { BufferGeometry, Color, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three'
import { color, float, uv } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events'
import type { ThreeEvent } from "@react-three/fiber"; const Y_OFFSET = 0.01
import { useCallback, useMemo, useRef } from "react"; const WALL_HEIGHT = 2.3
import {
BufferGeometry,
Color,
DoubleSide,
Float32BufferAttribute,
type Group,
Shape,
} from "three";
import { color, float, uv } from "three/tsl";
import { MeshBasicNodeMaterial } from "three/webgpu";
const Y_OFFSET = 0.01;
const WALL_HEIGHT = 2.3;
/** /**
* Creates a gradient material for zone walls using TSL * Creates a gradient material for zone walls using TSL
* Gradient goes from zone color at bottom to transparent at top * Gradient goes from zone color at bottom to transparent at top
*/ */
const createWallGradientMaterial = (zoneColor: string) => { const createWallGradientMaterial = (zoneColor: string) => {
const baseColor = color(new Color(zoneColor)); const baseColor = color(new Color(zoneColor))
// Use UV y coordinate for vertical gradient (0 at bottom, 1 at top) // Use UV y coordinate for vertical gradient (0 at bottom, 1 at top)
const gradientT = uv().y; const gradientT = uv().y
// Fade opacity from 0.6 at bottom to 0 at top // Fade opacity from 0.6 at bottom to 0 at top
const opacity = float(0.6).mul(float(1).sub(gradientT)); const opacity = float(0.6).mul(float(1).sub(gradientT))
return new MeshBasicNodeMaterial({ return new MeshBasicNodeMaterial({
transparent: true, transparent: true,
@@ -35,14 +27,14 @@ const createWallGradientMaterial = (zoneColor: string) => {
opacityNode: opacity, opacityNode: opacity,
side: DoubleSide, side: DoubleSide,
depthWrite: false, depthWrite: false,
}); })
}; }
/** /**
* Creates a floor material for zones using TSL * Creates a floor material for zones using TSL
*/ */
const createFloorMaterial = (zoneColor: string) => { const createFloorMaterial = (zoneColor: string) => {
const baseColor = color(new Color(zoneColor)); const baseColor = color(new Color(zoneColor))
return new MeshBasicNodeMaterial({ return new MeshBasicNodeMaterial({
transparent: true, transparent: true,
@@ -50,150 +42,114 @@ const createFloorMaterial = (zoneColor: string) => {
opacityNode: float(0.15), opacityNode: float(0.15),
side: DoubleSide, side: DoubleSide,
depthWrite: false, depthWrite: false,
}); })
}; }
/** /**
* Creates wall geometry for zone borders * Creates wall geometry for zone borders
* Each wall segment is a vertical quad from one polygon point to the next * Each wall segment is a vertical quad from one polygon point to the next
*/ */
const createWallGeometry = ( const createWallGeometry = (polygon: Array<[number, number]>): BufferGeometry => {
polygon: Array<[number, number]>, const geometry = new BufferGeometry()
): BufferGeometry => {
const geometry = new BufferGeometry();
if (polygon.length < 2) return geometry; if (polygon.length < 2) return geometry
const positions: number[] = []; const positions: number[] = []
const uvs: number[] = []; const uvs: number[] = []
const indices: number[] = []; const indices: number[] = []
// Create a wall segment for each edge of the polygon // Create a wall segment for each edge of the polygon
for (let i = 0; i < polygon.length; i++) { for (let i = 0; i < polygon.length; i++) {
const current = polygon[i]!; const current = polygon[i]!
const next = polygon[(i + 1) % polygon.length]!; const next = polygon[(i + 1) % polygon.length]!
const baseIndex = i * 4; const baseIndex = i * 4
// Four vertices per wall segment (two triangles forming a quad) // Four vertices per wall segment (two triangles forming a quad)
// Bottom-left // Bottom-left
positions.push(current[0]!, Y_OFFSET, current[1]!); positions.push(current[0]!, Y_OFFSET, current[1]!)
uvs.push(0, 0); uvs.push(0, 0)
// Bottom-right // Bottom-right
positions.push(next[0]!, Y_OFFSET, next[1]!); positions.push(next[0]!, Y_OFFSET, next[1]!)
uvs.push(1, 0); uvs.push(1, 0)
// Top-right // Top-right
positions.push(next[0]!, Y_OFFSET + WALL_HEIGHT, next[1]!); positions.push(next[0]!, Y_OFFSET + WALL_HEIGHT, next[1]!)
uvs.push(1, 1); uvs.push(1, 1)
// Top-left // Top-left
positions.push(current[0]!, Y_OFFSET + WALL_HEIGHT, current[1]!); positions.push(current[0]!, Y_OFFSET + WALL_HEIGHT, current[1]!)
uvs.push(0, 1); uvs.push(0, 1)
// Two triangles for the quad // Two triangles for the quad
indices.push( indices.push(baseIndex, baseIndex + 1, baseIndex + 2, baseIndex, baseIndex + 2, baseIndex + 3)
baseIndex,
baseIndex + 1,
baseIndex + 2,
baseIndex,
baseIndex + 2,
baseIndex + 3,
);
} }
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3)); geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.setAttribute("uv", new Float32BufferAttribute(uvs, 2)); geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
geometry.setIndex(indices); geometry.setIndex(indices)
geometry.computeVertexNormals(); geometry.computeVertexNormals()
return geometry; return geometry
}; }
export const ZoneRenderer = ({ zoneId }: { zoneId: Zone["id"] }) => { export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
const ref = useRef<Group>(null!); const ref = useRef<Group>(null!)
const zone = useScene((state) => state.zones[zoneId]);
useRegistry(zoneId, "zone", ref); useRegistry(node.id, 'zone', ref)
// Create floor shape from polygon // Create floor shape from polygon
const floorShape = useMemo(() => { const floorShape = useMemo(() => {
if (!zone?.polygon || zone.polygon.length < 3) return null; if (!node?.polygon || node.polygon.length < 3) return null
const shape = new Shape()
const shape = new Shape(); const firstPt = node.polygon[0]!
const firstPt = zone.polygon[0]!;
// Shape is in X-Y plane, we rotate it to X-Z plane // Shape is in X-Y plane, we rotate it to X-Z plane
// Negate Y (which becomes Z) to get correct orientation // Negate Y (which becomes Z) to get correct orientation
shape.moveTo(firstPt[0]!, -firstPt[1]!); shape.moveTo(firstPt[0]!, -firstPt[1]!)
for (let i = 1; i < zone.polygon.length; i++) { for (let i = 1; i < node.polygon.length; i++) {
const pt = zone.polygon[i]!; const pt = node.polygon[i]!
shape.lineTo(pt[0]!, -pt[1]!); shape.lineTo(pt[0]!, -pt[1]!)
} }
shape.closePath(); shape.closePath()
return shape; return shape
}, [zone?.polygon]); }, [node?.polygon])
// Create wall geometry from polygon // Create wall geometry from polygon
const wallGeometry = useMemo(() => { const wallGeometry = useMemo(() => {
if (!zone?.polygon || zone.polygon.length < 2) return null; if (!node?.polygon || node.polygon.length < 2) return null
return createWallGeometry(zone.polygon); return createWallGeometry(node.polygon)
}, [zone?.polygon]); }, [node?.polygon])
// Create materials // Create materials
const floorMaterial = useMemo(() => { const floorMaterial = useMemo(() => {
if (!zone?.color) return null; if (!node?.color) return null
return createFloorMaterial(zone.color); return createFloorMaterial(node.color)
}, [zone?.color]); }, [node?.color])
const wallMaterial = useMemo(() => { const wallMaterial = useMemo(() => {
if (!zone?.color) return null; if (!node?.color) return null
return createWallGradientMaterial(zone.color); return createWallGradientMaterial(node.color)
}, [zone?.color]); }, [node?.color])
if ( const handlers = useNodeEvents(node, 'zone')
!zone ||
!floorShape || if (!node || !floorShape || !wallGeometry || !floorMaterial || !wallMaterial) {
!wallGeometry || return null
!floorMaterial ||
!wallMaterial
) {
return null;
} }
const emitZoneEvent = useCallback(
(suffix: string, e: ThreeEvent<PointerEvent>) => {
const eventKey = `zone:${suffix}` as `zone:${typeof suffix}`;
emitter.emit(eventKey, {
zone,
position: [e.point.x, e.point.y, e.point.z],
stopPropagation: () => e.stopPropagation(),
nativeEvent: e,
});
},
[zone]
);
return ( return (
<group <group ref={ref} {...handlers}>
ref={ref}
onClick={(e) => emitZoneEvent("click", e)}
onPointerEnter={(e) => emitZoneEvent("enter", e)}
onPointerLeave={(e) => emitZoneEvent("leave", e)}
>
{/* Floor fill */} {/* Floor fill */}
<mesh <mesh position={[0, Y_OFFSET, 0]} rotation={[-Math.PI / 2, 0, 0]} material={floorMaterial}>
position={[0, Y_OFFSET, 0]}
rotation={[-Math.PI / 2, 0, 0]}
material={floorMaterial}
>
<shapeGeometry args={[floorShape]} /> <shapeGeometry args={[floorShape]} />
</mesh> </mesh>
{/* Wall borders with gradient */} {/* Wall borders with gradient */}
<mesh geometry={wallGeometry} material={wallMaterial} /> <mesh geometry={wallGeometry} material={wallMaterial} />
</group> </group>
); )
}; }
+30 -32
View File
@@ -1,4 +1,5 @@
import { import {
type BuildingEvent,
type BuildingNode, type BuildingNode,
type EventSuffix, type EventSuffix,
emitter, emitter,
@@ -6,56 +7,53 @@ import {
type ItemNode, type ItemNode,
type WallEvent, type WallEvent,
type WallNode, type WallNode,
} from "@pascal-app/core"; type ZoneEvent,
import type { ThreeEvent } from "@react-three/fiber"; type ZoneNode,
import type { BuildingEvent } from "../../../core/src/events/bus"; } from '@pascal-app/core'
import type { ThreeEvent } from '@react-three/fiber'
type NodeConfig = { type NodeConfig = {
item: { node: ItemNode; event: ItemEvent }; item: { node: ItemNode; event: ItemEvent }
wall: { node: WallNode; event: WallEvent }; wall: { node: WallNode; event: WallEvent }
building: { node: BuildingNode; event: BuildingEvent }; building: { node: BuildingNode; event: BuildingEvent }
}; zone: { node: ZoneNode; event: ZoneEvent }
}
type NodeType = keyof NodeConfig; type NodeType = keyof NodeConfig
export function useNodeEvents<T extends NodeType>( export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]['node'], type: T) {
node: NodeConfig[T]["node"],
type: T,
) {
const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => { const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => {
const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}`; const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}`
const localPoint = e.object.worldToLocal(e.point.clone()); const localPoint = e.object.worldToLocal(e.point.clone())
const payload = { const payload = {
node, node,
position: [e.point.x, e.point.y, e.point.z], position: [e.point.x, e.point.y, e.point.z],
localPosition: [localPoint.x, localPoint.y, localPoint.z], localPosition: [localPoint.x, localPoint.y, localPoint.z],
normal: e.face normal: e.face ? [e.face.normal.x, e.face.normal.y, e.face.normal.z] : undefined,
? [e.face.normal.x, e.face.normal.y, e.face.normal.z]
: undefined,
stopPropagation: () => e.stopPropagation(), stopPropagation: () => e.stopPropagation(),
nativeEvent: e, nativeEvent: e,
} as NodeConfig[T]["event"]; } as NodeConfig[T]['event']
emitter.emit(eventKey, payload); emitter.emit(eventKey, payload)
}; }
return { return {
onPointerDown: (e: ThreeEvent<PointerEvent>) => { onPointerDown: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return; if (e.button !== 0) return
emit("pointerdown", e); emit('pointerdown', e)
}, },
onPointerUp: (e: ThreeEvent<PointerEvent>) => { onPointerUp: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return; if (e.button !== 0) return
emit("pointerup", e); emit('pointerup', e)
}, },
onClick: (e: ThreeEvent<PointerEvent>) => { onClick: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return; if (e.button !== 0) return
emit("click", e); emit('click', e)
}, },
onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit("enter", e), onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit('enter', e),
onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit("leave", e), onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit('leave', e),
onPointerMove: (e: ThreeEvent<PointerEvent>) => emit("move", e), onPointerMove: (e: ThreeEvent<PointerEvent>) => emit('move', e),
onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit("double-click", e), onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit('double-click', e),
onContextMenu: (e: ThreeEvent<PointerEvent>) => emit("context-menu", e), onContextMenu: (e: ThreeEvent<PointerEvent>) => emit('context-menu', e),
}; }
} }