Skip to content

Commit 98d085a

Browse files
committed
feat(soba): pointerlockcontrols
1 parent d376f54 commit 98d085a

File tree

2 files changed

+161
-0
lines changed

2 files changed

+161
-0
lines changed

libs/soba/controls/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './lib/camera-controls';
22
export * from './lib/orbit-controls';
3+
export * from './lib/pointer-lock-controls';
34
export * from './lib/scroll-controls';
45
export * from './lib/trackball-controls';
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import {
2+
ChangeDetectionStrategy,
3+
Component,
4+
computed,
5+
CUSTOM_ELEMENTS_SCHEMA,
6+
DOCUMENT,
7+
effect,
8+
inject,
9+
input,
10+
output,
11+
untracked,
12+
} from '@angular/core';
13+
import { injectStore, NgtArgs, NgtOverwrite, NgtThreeElement, omit, pick } from 'angular-three';
14+
import { mergeInputs } from 'ngxtension/inject-inputs';
15+
import * as THREE from 'three';
16+
import { PointerLockControls } from 'three-stdlib';
17+
18+
export type NgtsPointerLockControlsOptions = Omit<
19+
NgtOverwrite<
20+
NgtThreeElement<typeof PointerLockControls>,
21+
{
22+
camera?: THREE.Camera;
23+
domElement?: HTMLElement;
24+
makeDefault: boolean;
25+
enabled: boolean;
26+
selector?: string;
27+
}
28+
>,
29+
'attach' | 'addEventListener' | 'removeEventListener' | 'parameters' | '___ngt_args__' | '_domElementKeyEvents'
30+
>;
31+
32+
const defaultOptions: NgtsPointerLockControlsOptions = {
33+
enabled: true,
34+
makeDefault: false,
35+
};
36+
37+
@Component({
38+
selector: 'ngts-pointer-lock-controls',
39+
template: `
40+
<ngt-primitive *args="[controls()]" [parameters]="parameters()">
41+
<ng-content />
42+
</ngt-primitive>
43+
`,
44+
changeDetection: ChangeDetectionStrategy.OnPush,
45+
imports: [NgtArgs],
46+
schemas: [CUSTOM_ELEMENTS_SCHEMA],
47+
})
48+
export class NgtsPointerLockControls {
49+
options = input(defaultOptions, { transform: mergeInputs(defaultOptions) });
50+
51+
lock = output<THREE.Event>();
52+
unlock = output<THREE.Event>();
53+
change = output<THREE.Event>();
54+
55+
protected parameters = omit(this.options, ['camera', 'domElement', 'makeDefault', 'enabled', 'selector']);
56+
57+
private document = inject(DOCUMENT);
58+
private store = injectStore();
59+
60+
private camera = pick(this.options, 'camera');
61+
private domElement = pick(this.options, 'domElement');
62+
private makeDefault = pick(this.options, 'makeDefault');
63+
private enabled = pick(this.options, 'enabled');
64+
private selector = pick(this.options, 'selector');
65+
66+
controls = computed(() => {
67+
const [camera, defaultCamera] = [this.camera(), this.store.camera()];
68+
const controlsCamera = camera || defaultCamera;
69+
return new PointerLockControls(controlsCamera);
70+
});
71+
72+
private element = computed(() => {
73+
const domElement = this.domElement();
74+
if (domElement) return domElement;
75+
76+
const connected = this.store.events.connected?.();
77+
if (connected) return connected;
78+
79+
return this.store.gl.domElement();
80+
});
81+
82+
constructor() {
83+
effect((onCleanup) => {
84+
const makeDefault = this.makeDefault();
85+
if (!makeDefault) return;
86+
87+
const controls = this.controls();
88+
const oldControls = this.store.snapshot.controls;
89+
this.store.update({ controls });
90+
onCleanup(() => void this.store.update({ controls: oldControls }));
91+
});
92+
93+
effect((onCleanup) => {
94+
const controls = this.controls();
95+
if (!controls) return;
96+
97+
const enabled = this.enabled();
98+
if (!enabled) return;
99+
100+
controls.connect(untracked(this.element));
101+
// force events to be centered while PLC is active
102+
const oldComputeOffsets = this.store.snapshot.events.compute;
103+
104+
this.store.update((prevState) => ({
105+
events: {
106+
...prevState.events,
107+
compute(event, root) {
108+
const state = root.snapshot;
109+
const offsetX = state.size.width / 2;
110+
const offsetY = state.size.height / 2;
111+
state.pointer.set((offsetX / state.size.width) * 2 - 1, -(offsetY / state.size.height) * 2 + 1);
112+
state.raycaster.setFromCamera(state.pointer, state.camera);
113+
},
114+
},
115+
}));
116+
117+
onCleanup(() => {
118+
controls.disconnect();
119+
this.store.update((prevState) => ({
120+
events: { ...prevState.events, compute: oldComputeOffsets },
121+
}));
122+
});
123+
});
124+
125+
effect((onCleanup) => {
126+
const [controls, invalidate, selector] = [this.controls(), this.store.invalidate(), this.selector()];
127+
128+
const callback = (e: THREE.Event) => {
129+
invalidate();
130+
if (this.change) this.change.emit(e);
131+
};
132+
133+
const lockCallback = this.lock.emit.bind(this.lock);
134+
const unlockCallback = this.unlock.emit.bind(this.unlock);
135+
136+
controls.addEventListener('change', callback);
137+
controls.addEventListener('lock', lockCallback);
138+
controls.addEventListener('unlock', unlockCallback);
139+
140+
// enforce previous interaction
141+
const handler = controls.lock.bind(controls);
142+
const elements = selector ? Array.from(this.document.querySelectorAll(selector)) : [this.document];
143+
for (const element of elements) {
144+
if (!element) continue;
145+
element.addEventListener('click', handler);
146+
}
147+
148+
onCleanup(() => {
149+
controls.removeEventListener('change', callback);
150+
controls.removeEventListener('lock', lockCallback);
151+
controls.removeEventListener('unlock', unlockCallback);
152+
153+
for (const element of elements) {
154+
if (!element) continue;
155+
element.removeEventListener('click', handler);
156+
}
157+
});
158+
});
159+
}
160+
}

0 commit comments

Comments
 (0)