events handlers

This commit is contained in:
wass08
2026-01-16 11:42:10 +09:00
parent d657f4125f
commit 1b954d3416
12 changed files with 135 additions and 266 deletions
+32 -31
View File
@@ -1,45 +1,46 @@
import mitt from "mitt"; import mitt from "mitt";
import { ItemNode, WallNode } from "../schema"; import { ItemNode, WallNode } from "../schema";
import { AnyNode } from "../schema/types"; import { AnyNode } from "../schema/types";
// Base event interfaces
export interface GridEvent { export interface GridEvent {
position: [number, number]; position: [number, number];
} }
export interface NodeEvent { export interface NodeEvent<T extends AnyNode = AnyNode> {
node: AnyNode; node: T;
position: [number, number, number]; // [x, y, z] world coordinates position: [number, number, number];
normal?: [number, number, number]; // [x, y, z] normal vector normal?: [number, number, number];
stopPropagation: () => void; stopPropagation: () => void;
} }
export interface WallEvent extends NodeEvent { export type WallEvent = NodeEvent<WallNode>;
node: WallNode; export type ItemEvent = NodeEvent<ItemNode>;
}
export interface ItemEvent extends NodeEvent { // Event suffixes - exported for use in hooks
node: ItemNode; export const eventSuffixes = [
} "click",
"move",
"enter",
"leave",
"pointerdown",
"pointerup",
"context-menu",
"double-click",
] as const;
type EditorEvents = { export type EventSuffix = (typeof eventSuffixes)[number];
"grid:click": GridEvent;
"grid:rightclick": GridEvent; type NodeEvents<T extends string, E> = {
"grid:move": GridEvent; [K in `${T}:${EventSuffix}`]: E;
"grid:double-click": GridEvent;
"grid:enter": GridEvent;
"grid:leave": GridEvent;
"grid:pointerdown": GridEvent;
"grid:pointerup": GridEvent;
"wall:click": WallEvent;
"wall:move": WallEvent;
"wall:enter": WallEvent;
"wall:leave": WallEvent;
"wall:pointerdown": WallEvent;
"wall:pointerup": WallEvent;
"item:click": ItemEvent;
"item:move": ItemEvent;
"item:enter": ItemEvent;
"item:leave": ItemEvent;
"item:pointerdown": ItemEvent;
"item:pointerup": ItemEvent;
}; };
type GridEvents = {
[K in `grid:${EventSuffix}`]: GridEvent;
};
type EditorEvents = GridEvents &
NodeEvents<"wall", WallEvent> &
NodeEvents<"item", ItemEvent>;
export const emitter = mitt<EditorEvents>(); export const emitter = mitt<EditorEvents>();
@@ -9,6 +9,7 @@ export const sceneRegistry = {
// Categorized lookups: Type -> Set of IDs // Categorized lookups: Type -> Set of IDs
// Using a Set is faster for adding/deleting than an Array // Using a Set is faster for adding/deleting than an Array
byType: { byType: {
building: new Set<string>(),
level: new Set<string>(), level: new Set<string>(),
wall: new Set<string>(), wall: new Set<string>(),
item: new Set<string>(), item: new Set<string>(),
+2 -1
View File
@@ -12,7 +12,8 @@ export { LevelSystem } from "../../viewer/src/systems/level/level-system";
export { WallSystem } from "./systems/wall/wall-system"; export { WallSystem } from "./systems/wall/wall-system";
// Events // Events
export { emitter } from "./events/bus"; export { emitter, eventSuffixes } from "./events/bus";
export type { ItemEvent, WallEvent, NodeEvent, GridEvent, EventSuffix } from "./events/bus";
// Schema // Schema
export * from "./schema"; export * from "./schema";
+1 -1
View File
@@ -6,7 +6,7 @@ import { LevelNode } from "./level";
export const BuildingNode = BaseNode.extend({ export const BuildingNode = BaseNode.extend({
id: objectId("building"), id: objectId("building"),
type: nodeType("building"), type: nodeType("building"),
children: z.array(LevelNode).default([LevelNode.parse({})]), children: z.array(LevelNode.shape.id).default([]),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
}).describe( }).describe(
+11 -2
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { create } from "zustand"; import { create } from "zustand";
import { ItemNode } from "../schema"; import { BuildingNode, ItemNode } from "../schema";
import { LevelNode } from "../schema/nodes/level"; import { LevelNode } from "../schema/nodes/level";
import { WallNode } from "../schema/nodes/wall"; import { WallNode } from "../schema/nodes/wall";
import { AnyNode, AnyNodeId } from "../schema/types"; import { AnyNode, AnyNodeId } from "../schema/types";
@@ -33,6 +33,12 @@ const useScene = create<SceneState>()((set, get) => ({
dirtyNodes: new Set<AnyNodeId>(), dirtyNodes: new Set<AnyNodeId>(),
loadScene: () => { loadScene: () => {
const building = BuildingNode.parse({
children: [],
});
const level0 = LevelNode.parse({ const level0 = LevelNode.parse({
level: 0, level: 0,
children: [], children: [],
@@ -85,8 +91,11 @@ const useScene = create<SceneState>()((set, get) => ({
level0.children.push(wall0.id, wall1.id, wall2.id, wall3.id); level0.children.push(wall0.id, wall1.id, wall2.id, wall3.id);
building.children.push(level0.id, level1.id, level2.id);
// Define all nodes flat // Define all nodes flat
const nodes: Record<AnyNodeId, AnyNode> = { const nodes: Record<AnyNodeId, AnyNode> = {
[building.id]: building,
[level0.id]: level0, [level0.id]: level0,
[level1.id]: level1, [level1.id]: level1,
[level2.id]: level2, [level2.id]: level2,
@@ -98,7 +107,7 @@ const useScene = create<SceneState>()((set, get) => ({
}; };
// Root nodes are the levels // Root nodes are the levels
const rootNodeIds = [level0.id, level1.id, level2.id]; const rootNodeIds = [building.id];
get().dirtyNodes.add(wall0.id); get().dirtyNodes.add(wall0.id);
get().dirtyNodes.add(wall1.id); get().dirtyNodes.add(wall1.id);
@@ -0,0 +1,21 @@
import { BuildingNode, LevelNode, useRegistry } from "@pascal-app/core";
import { useRef } from "react";
import { Group } from "three";
import { NodeRenderer } from "../node-renderer";
export const BuildingRenderer = ({ node }: { node: BuildingNode }) => {
const ref = useRef<Group>(null!);
useRegistry(node.id, node.type, ref);
return (
<group ref={ref}>
{/* <mesh receiveShadow>
<boxGeometry args={[10, 0.1, 10]} />
<meshStandardMaterial color="orange" />
</mesh> */}
{node.children.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
</group>
);
};
@@ -4,6 +4,7 @@ import { useGLTF } from "@react-three/drei/core/Gltf";
import { ThreeEvent } from "@react-three/fiber/dist/declarations/src/core/events"; import { ThreeEvent } from "@react-three/fiber/dist/declarations/src/core/events";
import { useCallback, useRef } from "react"; import { useCallback, useRef } from "react";
import { Group } from "three"; import { Group } from "three";
import { useNodeEvents } from "../../../hooks/use-node-events";
export const ItemRenderer = ({ node }: { node: ItemNode }) => { export const ItemRenderer = ({ node }: { node: ItemNode }) => {
const ref = useRef<Group>(null!); const ref = useRef<Group>(null!);
@@ -15,114 +16,7 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
nodes.cutout.visible = false; nodes.cutout.visible = false;
} }
// Event handlers const handlers = useNodeEvents(node, "item");
const onPointerDown = useCallback(
(e: ThreeEvent<PointerEvent>) => {
// Only emit events for left-click (button 0)
if (e.button !== 0) return;
const eventData = {
node,
position: [e.point.x, e.point.y, e.point.z] as [number, number, number],
normal: e.face
? ([e.face.normal.x, e.face.normal.y, e.face.normal.z] as [
number,
number,
number
])
: undefined,
stopPropagation: () => e.stopPropagation(),
};
emitter.emit("item:pointerdown", eventData);
},
[node]
);
const onClick = useCallback(
(e: ThreeEvent<PointerEvent>) => {
// Only emit events for left-click (button 0)
if (e.button !== 0) return;
const eventData = {
node,
position: [e.point.x, e.point.y, e.point.z] as [number, number, number],
normal: e.face
? ([e.face.normal.x, e.face.normal.y, e.face.normal.z] as [
number,
number,
number
])
: undefined,
stopPropagation: () => e.stopPropagation(),
};
emitter.emit("item:click", eventData);
},
[node]
);
const onPointerUp = useCallback(
(e: ThreeEvent<PointerEvent>) => {
// Only emit events for left-click (button 0)
if (e.button !== 0) return;
emitter.emit("item:pointerup", {
node,
position: [e.point.x, e.point.y, e.point.z],
normal: e.face
? ([e.face.normal.x, e.face.normal.y, e.face.normal.z] as [
number,
number,
number
])
: undefined,
stopPropagation: () => e.stopPropagation(),
});
},
[node]
);
const onPointerEnter = useCallback(
(e: ThreeEvent<PointerEvent>) => {
emitter.emit("item:enter", {
node,
position: [e.point.x, e.point.y, e.point.z],
normal: e.face
? [e.face.normal.x, e.face.normal.y, e.face.normal.z]
: undefined,
stopPropagation: () => e.stopPropagation(),
});
},
[node]
);
const onPointerLeave = useCallback(
(e: ThreeEvent<PointerEvent>) => {
emitter.emit("item:leave", {
node,
position: [e.point.x, e.point.y, e.point.z],
normal: e.face
? [e.face.normal.x, e.face.normal.y, e.face.normal.z]
: undefined,
stopPropagation: () => e.stopPropagation(),
});
},
[node]
);
const onPointerMove = useCallback(
(e: ThreeEvent<PointerEvent>) => {
emitter.emit("item:move", {
node,
position: [e.point.x, e.point.y, e.point.z],
normal: e.face
? [e.face.normal.x, e.face.normal.y, e.face.normal.z]
: undefined,
stopPropagation: () => e.stopPropagation(),
});
},
[node]
);
return ( return (
<Clone <Clone
@@ -130,12 +24,7 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
object={scene} object={scene}
position={node.position} position={node.position}
rotation={node.rotation} rotation={node.rotation}
onPointerDown={onPointerDown} {...handlers}
onClick={onClick}
onPointerUp={onPointerUp}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onPointerMove={onPointerMove}
/> />
); );
}; };
@@ -9,10 +9,10 @@ export const LevelRenderer = ({ node }: { node: LevelNode }) => {
useRegistry(node.id, node.type, ref); useRegistry(node.id, node.type, ref);
return ( return (
<group ref={ref}> <group ref={ref}>
<mesh receiveShadow> {/* <mesh receiveShadow>
<boxGeometry args={[10, 0.1, 10]} /> <boxGeometry args={[10, 0.1, 10]} />
<meshStandardMaterial color="orange" /> <meshStandardMaterial color="orange" />
</mesh> </mesh> */}
{node.children.map((childId) => ( {node.children.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} /> <NodeRenderer key={childId} nodeId={childId} />
))} ))}
@@ -4,6 +4,7 @@ import { AnyNode, useScene } from "@pascal-app/core";
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 { BuildingRenderer } from "./building/building-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]);
@@ -12,6 +13,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode["id"] }) => {
return ( return (
<> <>
{node.type === "building" && <BuildingRenderer node={node} />}
{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} />}
@@ -3,132 +3,21 @@ import { ThreeEvent } from "@react-three/fiber";
import { useCallback, useRef } from "react"; import { useCallback, useRef } from "react";
import { Mesh } from "three"; import { Mesh } from "three";
import { NodeRenderer } from "../node-renderer"; import { NodeRenderer } from "../node-renderer";
import { useNodeEvents } from "../../../hooks/use-node-events";
export const WallRenderer = ({ node }: { node: WallNode }) => { export const WallRenderer = ({ node }: { node: WallNode }) => {
const ref = useRef<Mesh>(null!); const ref = useRef<Mesh>(null!);
useRegistry(node.id, "wall", ref); useRegistry(node.id, "wall", ref);
// Event handlers const handlers = useNodeEvents(node, "wall");
const onPointerDown = useCallback(
(e: ThreeEvent<PointerEvent>) => {
// Only emit events for left-click (button 0)
if (e.button !== 0) return;
const eventData = {
node,
position: [e.point.x, e.point.y, e.point.z] as [number, number, number],
normal: e.face
? ([e.face.normal.x, e.face.normal.y, e.face.normal.z] as [
number,
number,
number
])
: undefined,
stopPropagation: () => e.stopPropagation(),
};
emitter.emit("wall:pointerdown", eventData);
},
[node]
);
const onClick = useCallback(
(e: ThreeEvent<PointerEvent>) => {
// Only emit events for left-click (button 0)
if (e.button !== 0) return;
const eventData = {
node,
position: [e.point.x, e.point.y, e.point.z] as [number, number, number],
normal: e.face
? ([e.face.normal.x, e.face.normal.y, e.face.normal.z] as [
number,
number,
number
])
: undefined,
stopPropagation: () => e.stopPropagation(),
};
emitter.emit("wall:click", eventData);
},
[node]
);
const onPointerUp = useCallback(
(e: ThreeEvent<PointerEvent>) => {
// Only emit events for left-click (button 0)
if (e.button !== 0) return;
emitter.emit("wall:pointerup", {
node,
position: [e.point.x, e.point.y, e.point.z],
normal: e.face
? ([e.face.normal.x, e.face.normal.y, e.face.normal.z] as [
number,
number,
number
])
: undefined,
stopPropagation: () => e.stopPropagation(),
});
},
[node]
);
const onPointerEnter = useCallback(
(e: ThreeEvent<PointerEvent>) => {
emitter.emit("wall:enter", {
node,
position: [e.point.x, e.point.y, e.point.z],
normal: e.face
? [e.face.normal.x, e.face.normal.y, e.face.normal.z]
: undefined,
stopPropagation: () => e.stopPropagation(),
});
},
[node]
);
const onPointerLeave = useCallback(
(e: ThreeEvent<PointerEvent>) => {
emitter.emit("wall:leave", {
node,
position: [e.point.x, e.point.y, e.point.z],
normal: e.face
? [e.face.normal.x, e.face.normal.y, e.face.normal.z]
: undefined,
stopPropagation: () => e.stopPropagation(),
});
},
[node]
);
const onPointerMove = useCallback(
(e: ThreeEvent<PointerEvent>) => {
emitter.emit("wall:move", {
node,
position: [e.point.x, e.point.y, e.point.z],
normal: e.face
? [e.face.normal.x, e.face.normal.y, e.face.normal.z]
: undefined,
stopPropagation: () => e.stopPropagation(),
});
},
[node]
);
return ( return (
<mesh <mesh
ref={ref} ref={ref}
castShadow castShadow
receiveShadow receiveShadow
onPointerDown={onPointerDown} {...handlers}
onPointerUp={onPointerUp}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onPointerMove={onPointerMove}
onClick={onClick}
> >
{/* WallSystem will replace this geometry in the next frame */} {/* WallSystem will replace this geometry in the next frame */}
<boxGeometry args={[0, 0, 0]} /> <boxGeometry args={[0, 0, 0]} />
@@ -0,0 +1,52 @@
import {
emitter,
EventSuffix,
ItemEvent,
ItemNode,
WallEvent,
WallNode,
} from "@pascal-app/core";
import { ThreeEvent } from "@react-three/fiber";
type NodeConfig = {
item: { node: ItemNode; event: ItemEvent };
wall: { node: WallNode; event: WallEvent };
};
type NodeType = keyof NodeConfig;
export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]["node"], type: T) {
const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => {
const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}`;
const payload = {
node,
position: [e.point.x, e.point.y, e.point.z],
normal: e.face
? [e.face.normal.x, e.face.normal.y, e.face.normal.z]
: undefined,
stopPropagation: () => e.stopPropagation(),
} as NodeConfig[T]["event"];
emitter.emit(eventKey, payload);
};
return {
onPointerDown: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return;
emit("pointerdown", e);
},
onPointerUp: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return;
emit("pointerup", e);
},
onClick: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return;
emit("click", e);
},
onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit("enter", e),
onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit("leave", e),
onPointerMove: (e: ThreeEvent<PointerEvent>) => emit("move", e),
onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit("double-click", e),
onContextMenu: (e: ThreeEvent<PointerEvent>) => emit("context-menu", e),
};
}
+5 -1
View File
@@ -1,12 +1,14 @@
"use client"; "use client";
import { LevelNode } from "@pascal-app/core"; import { BuildingNode, LevelNode } from "@pascal-app/core";
import { create } from "zustand"; import { create } from "zustand";
type ViewerState = { type ViewerState = {
levelMode: "stacked" | "exploded" | "solo" | "manual"; levelMode: "stacked" | "exploded" | "solo" | "manual";
setLevelMode: (mode: "stacked" | "exploded" | "solo" | "manual") => void; setLevelMode: (mode: "stacked" | "exploded" | "solo" | "manual") => void;
currentBuildingId: BuildingNode["id"] | null;
setCurrentBuildingId: (id: BuildingNode["id"] | null) => void;
currentLevelId: LevelNode["id"] | null; currentLevelId: LevelNode["id"] | null;
setCurrentLevelId: (id: LevelNode["id"] | null) => void; setCurrentLevelId: (id: LevelNode["id"] | null) => void;
}; };
@@ -16,6 +18,8 @@ const useViewer = create<ViewerState>()((set, get) => ({
setLevelMode: (mode) => set({ levelMode: mode }), setLevelMode: (mode) => set({ levelMode: mode }),
currentLevelId: null, currentLevelId: null,
setCurrentLevelId: (id) => set({ currentLevelId: id }), setCurrentLevelId: (id) => set({ currentLevelId: id }),
currentBuildingId: null,
setCurrentBuildingId: (id) => set({ currentBuildingId: id }),
})); }));
export default useViewer; export default useViewer;