zones as nodes
This commit is contained in:
@@ -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,6 +1,7 @@
|
|||||||
// Store
|
// Store
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
BuildingEvent,
|
||||||
CameraControlEvent,
|
CameraControlEvent,
|
||||||
EventSuffix,
|
EventSuffix,
|
||||||
GridEvent,
|
GridEvent,
|
||||||
@@ -27,4 +28,4 @@ export { default as useScene } from './store/use-scene'
|
|||||||
// Systems
|
// Systems
|
||||||
export { WallSystem } from './systems/wall/wall-system'
|
export { WallSystem } from './systems/wall/wall-system'
|
||||||
|
|
||||||
export { isObject } from './utils/types'
|
export { isObject } from './utils/types'
|
||||||
|
|||||||
@@ -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'
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user