Initial commit: Unity Needle AR Menu project with MenuScene and SampleScene web apps
Add root .gitignore for Unity Library/Temp/Logs, IDE folders, and node_modules. Include Assets, Needle TypeScript (MenuController, asset picker, WebXR), and project configuration. Made-with: Cursor
This commit is contained in:
117
Needle/MenuScene/src/assetPicker.ts
Normal file
117
Needle/MenuScene/src/assetPicker.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { NeedleXRSession, findObjectOfType } from "@needle-tools/engine";
|
||||
import type { MenuController } from "./scripts/MenuController.js";
|
||||
|
||||
/**
|
||||
* HTML overlay: drives {@link MenuController} prev/next (same GLB as Gitea AR-Menu) — no `src` swap.
|
||||
* Requires Unity export with MenuController + dish Object3Ds on the scene GLB.
|
||||
*/
|
||||
|
||||
function whenDomReady(fn: () => void): void {
|
||||
if (document.readyState !== "loading") fn();
|
||||
else document.addEventListener("DOMContentLoaded", () => fn(), { once: true });
|
||||
}
|
||||
|
||||
function initAssetPicker(): void {
|
||||
const needle = document.querySelector("needle-engine");
|
||||
const prev = document.querySelector<HTMLButtonElement>("#asset-picker-prev");
|
||||
const next = document.querySelector<HTMLButtonElement>("#asset-picker-next");
|
||||
const arBtn = document.querySelector<HTMLButtonElement>("#asset-picker-ar");
|
||||
const labelEl = document.querySelector("#asset-picker-label");
|
||||
const indexEl = document.querySelector("#asset-picker-index");
|
||||
|
||||
if (!needle || !prev || !next || !arBtn || !labelEl || !indexEl) return;
|
||||
|
||||
let menuController: MenuController | null = null;
|
||||
let immersiveSessionActive = false;
|
||||
let arSupported = false;
|
||||
let arStarting = false;
|
||||
|
||||
const syncUi = (): void => {
|
||||
if (menuController && menuController.getDishSlotCount() > 0) {
|
||||
labelEl.textContent = menuController.getPickerLabel();
|
||||
indexEl.textContent = "";
|
||||
} else if (menuController) {
|
||||
labelEl.textContent = "Menu (assign dishes in Unity)";
|
||||
indexEl.textContent = "";
|
||||
} else {
|
||||
labelEl.textContent = "Menu scene";
|
||||
indexEl.textContent = "—";
|
||||
}
|
||||
|
||||
const canNav = menuController !== null && menuController.getDishSlotCount() > 1;
|
||||
|
||||
prev.disabled = !canNav;
|
||||
next.disabled = !canNav;
|
||||
|
||||
arBtn.disabled =
|
||||
!arSupported ||
|
||||
arStarting ||
|
||||
immersiveSessionActive;
|
||||
};
|
||||
|
||||
const bindMenuController = async (): Promise<void> => {
|
||||
try {
|
||||
const ctx = await needle.getContext();
|
||||
menuController = findObjectOfType(MenuController, ctx);
|
||||
} catch {
|
||||
menuController = null;
|
||||
}
|
||||
syncUi();
|
||||
};
|
||||
|
||||
void NeedleXRSession.isARSupported().then((ok: boolean) => {
|
||||
arSupported = ok;
|
||||
syncUi();
|
||||
});
|
||||
|
||||
const requestNavigate = (delta: number): void => {
|
||||
if (!menuController || menuController.getDishSlotCount() <= 1) return;
|
||||
if (delta < 0) menuController.selectPreviousDish();
|
||||
else menuController.selectNextDish();
|
||||
syncUi();
|
||||
};
|
||||
|
||||
const startAr = async (): Promise<void> => {
|
||||
if (!arSupported || arStarting || immersiveSessionActive) return;
|
||||
arStarting = true;
|
||||
syncUi();
|
||||
try {
|
||||
const ctx = await needle.getContext();
|
||||
await NeedleXRSession.start("immersive-ar", undefined, ctx);
|
||||
} catch (err) {
|
||||
console.warn("[assetPicker] Failed to start AR session:", err);
|
||||
} finally {
|
||||
arStarting = false;
|
||||
syncUi();
|
||||
}
|
||||
};
|
||||
|
||||
prev.addEventListener("click", () => requestNavigate(-1));
|
||||
next.addEventListener("click", () => requestNavigate(1));
|
||||
arBtn.addEventListener("click", () => void startAr());
|
||||
|
||||
needle.addEventListener("enter-ar", () => {
|
||||
immersiveSessionActive = true;
|
||||
syncUi();
|
||||
});
|
||||
needle.addEventListener("exit-ar", () => {
|
||||
immersiveSessionActive = false;
|
||||
syncUi();
|
||||
});
|
||||
needle.addEventListener("enter-vr", () => {
|
||||
immersiveSessionActive = true;
|
||||
syncUi();
|
||||
});
|
||||
needle.addEventListener("exit-vr", () => {
|
||||
immersiveSessionActive = false;
|
||||
syncUi();
|
||||
});
|
||||
|
||||
needle.addEventListener("loadfinished", () => void bindMenuController());
|
||||
|
||||
whenDomReady(() => {
|
||||
requestAnimationFrame(() => void bindMenuController());
|
||||
});
|
||||
}
|
||||
|
||||
initAssetPicker();
|
||||
20
Needle/MenuScene/src/enableXR.ts
Normal file
20
Needle/MenuScene/src/enableXR.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { onStart, WebXR } from "@needle-tools/engine";
|
||||
|
||||
/**
|
||||
* WebXR (AR/VR) — https://engine.needle.tools/docs/how-to-guides/xr/
|
||||
*
|
||||
* Unity editor (parity with Gitea): use XR Flag on content so meshes stay visible in AR; author dish
|
||||
* scale for real-world size. If the scene already has WebXR from export, we only tune placement.
|
||||
*/
|
||||
onStart((context) => {
|
||||
let webxr = context.scene.getComponentInChildren(WebXR);
|
||||
if (!webxr) {
|
||||
webxr = context.scene.addComponent(WebXR);
|
||||
webxr.createARButton = true;
|
||||
webxr.createVRButton = true;
|
||||
}
|
||||
|
||||
webxr.autoPlace = true;
|
||||
webxr.autoCenter = true;
|
||||
webxr.arScale = 1;
|
||||
});
|
||||
3
Needle/MenuScene/src/main.ts
Normal file
3
Needle/MenuScene/src/main.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import("@needle-tools/engine") /* async import of needle engine */;
|
||||
import "./enableXR";
|
||||
import "./assetPicker";
|
||||
74
Needle/MenuScene/src/scripts/ARObjectController.ts
Normal file
74
Needle/MenuScene/src/scripts/ARObjectController.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Behaviour } from "@needle-tools/engine";
|
||||
import { Vector2, Raycaster, Vector3, Plane } from "three";
|
||||
|
||||
export class ARObjectController extends Behaviour {
|
||||
private raycaster: Raycaster = new Raycaster();
|
||||
private touchPos: Vector2 = new Vector2();
|
||||
private plane: Plane = new Plane(new Vector3(0, 1, 0), 0);
|
||||
|
||||
private initialPinchDistance: number = 0;
|
||||
private initialScale: Vector3 = new Vector3();
|
||||
private isScaling: boolean = false;
|
||||
|
||||
onEnable(): void {
|
||||
const canvas = this.context.renderer.domElement;
|
||||
canvas.addEventListener("touchstart", this.onTouchStart);
|
||||
canvas.addEventListener("touchmove", this.onTouchMove);
|
||||
canvas.addEventListener("touchend", this.onTouchEnd);
|
||||
}
|
||||
|
||||
onDisable(): void {
|
||||
const canvas = this.context.renderer.domElement;
|
||||
canvas.removeEventListener("touchstart", this.onTouchStart);
|
||||
canvas.removeEventListener("touchmove", this.onTouchMove);
|
||||
canvas.removeEventListener("touchend", this.onTouchEnd);
|
||||
}
|
||||
|
||||
private onTouchStart = (event: TouchEvent): void => {
|
||||
if (event.touches.length === 2) {
|
||||
this.isScaling = true;
|
||||
const touch1 = event.touches[0];
|
||||
const touch2 = event.touches[1];
|
||||
this.initialPinchDistance = Math.hypot(
|
||||
touch2.clientX - touch1.clientX,
|
||||
touch2.clientY - touch1.clientY
|
||||
);
|
||||
this.initialScale.copy(this.gameObject.scale);
|
||||
}
|
||||
};
|
||||
|
||||
private onTouchMove = (event: TouchEvent): void => {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.isScaling && event.touches.length === 2) {
|
||||
const touch1 = event.touches[0];
|
||||
const touch2 = event.touches[1];
|
||||
const currentDistance = Math.hypot(
|
||||
touch2.clientX - touch1.clientX,
|
||||
touch2.clientY - touch1.clientY
|
||||
);
|
||||
const scaleFactor = currentDistance / this.initialPinchDistance;
|
||||
const newScale = this.initialScale.clone().multiplyScalar(scaleFactor);
|
||||
this.gameObject.scale.copy(newScale);
|
||||
} else if (event.touches.length === 1 && !this.isScaling) {
|
||||
const touch = event.touches[0];
|
||||
const canvas = this.context.renderer.domElement;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
this.touchPos.set(
|
||||
((touch.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((touch.clientY - rect.top) / rect.height) * 2 + 1
|
||||
);
|
||||
this.raycaster.setFromCamera(this.touchPos, this.context.mainCamera);
|
||||
const intersection = new Vector3();
|
||||
if (this.raycaster.ray.intersectPlane(this.plane, intersection)) {
|
||||
this.gameObject.position.copy(intersection);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onTouchEnd = (event: TouchEvent): void => {
|
||||
if (event.touches.length < 2) {
|
||||
this.isScaling = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
341
Needle/MenuScene/src/scripts/MenuController.ts
Normal file
341
Needle/MenuScene/src/scripts/MenuController.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
Behaviour,
|
||||
DeviceUtilities,
|
||||
GameObject,
|
||||
serializable,
|
||||
USDZExporter,
|
||||
type NeedleXREventArgs,
|
||||
} from "@needle-tools/engine";
|
||||
import { Object3D } from "three";
|
||||
|
||||
const dishBaseY = new WeakMap<Object3D, number>();
|
||||
|
||||
/**
|
||||
* Place each dish model in the same Unity scene as MenuScene, assign the roots to {@link MenuController.dishes},
|
||||
* then export a single `MenuScene.glb`. Only one dish is active at a time; the HTML picker cycles entries for AR preview.
|
||||
*/
|
||||
export class MenuController extends Behaviour {
|
||||
isMobile: boolean = false;
|
||||
isDesktop: boolean = false;
|
||||
isXR: boolean = false;
|
||||
private dishName: string = "";
|
||||
|
||||
@serializable(Object3D)
|
||||
dishes: Object3D[] = [];
|
||||
|
||||
@serializable(Object3D)
|
||||
webXROrigin?: Object3D;
|
||||
|
||||
/** Local-space vertical bob amplitude (meters). Set to 0 to disable. */
|
||||
@serializable()
|
||||
dishBobAmplitude = 0.05;
|
||||
|
||||
/** Bob angular speed (radians per second). */
|
||||
@serializable()
|
||||
dishBobSpeed = 2.5;
|
||||
|
||||
private usdzExporter?: USDZExporter;
|
||||
|
||||
/** True while an immersive-ar session is active — vertical bob is paused. */
|
||||
private arSessionBobPaused = false;
|
||||
|
||||
selectedDishIndex: number = 0;
|
||||
|
||||
onEnable(): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
this.dishName = params.get("dishName") ?? "";
|
||||
|
||||
if (this.webXROrigin) this.usdzExporter = this.webXROrigin.getComponent(USDZExporter) ?? undefined;
|
||||
|
||||
if (this.dishName) {
|
||||
let matched = false;
|
||||
this.dishes.forEach((dish, index) => {
|
||||
if (!dish) return;
|
||||
if (dish.name === this.dishName) {
|
||||
this.selectedDishIndex = index;
|
||||
matched = true;
|
||||
}
|
||||
});
|
||||
this.dishes.forEach((dish) => {
|
||||
if (!dish) return;
|
||||
const on = matched && dish.name === this.dishName;
|
||||
if (!on) {
|
||||
this.restoreDishBaseY(dish);
|
||||
}
|
||||
GameObject.setActive(dish, on);
|
||||
});
|
||||
if (!matched) {
|
||||
this.ensureOnlySelectedDishVisible();
|
||||
}
|
||||
} else {
|
||||
this.ensureOnlySelectedDishVisible();
|
||||
}
|
||||
|
||||
this.updateUSDZExporterTarget();
|
||||
|
||||
void this.checkForDeviceType().then(() => {
|
||||
if (this.isMobile) {
|
||||
console.log("[MenuController] isMobile");
|
||||
} else if (this.isDesktop) {
|
||||
this.setupDesktopControls();
|
||||
} else if (this.isXR) {
|
||||
// XR-specific setup if needed
|
||||
}
|
||||
});
|
||||
|
||||
this.setupMobileControls();
|
||||
this.disableDoubleTapZoom();
|
||||
}
|
||||
|
||||
onEnterXR(args: NeedleXREventArgs): void {
|
||||
if (args.xr.mode === "immersive-ar") {
|
||||
this.arSessionBobPaused = true;
|
||||
this.snapActiveDishToBaseY();
|
||||
}
|
||||
}
|
||||
|
||||
onLeaveXR(_args: NeedleXREventArgs): void {
|
||||
this.arSessionBobPaused = false;
|
||||
}
|
||||
|
||||
update(): void {
|
||||
if (this.arSessionBobPaused) return;
|
||||
if (this.dishBobAmplitude <= 0) return;
|
||||
|
||||
const valid = this.getValidDishIndices();
|
||||
if (valid.length === 0) return;
|
||||
|
||||
const dish = this.dishes[this.selectedDishIndex];
|
||||
if (!dish) return;
|
||||
|
||||
let base = dishBaseY.get(dish);
|
||||
if (base === undefined) {
|
||||
base = dish.position.y;
|
||||
dishBaseY.set(dish, base);
|
||||
}
|
||||
|
||||
const t = this.context.time.time;
|
||||
dish.position.y = base + Math.sin(t * this.dishBobSpeed) * this.dishBobAmplitude;
|
||||
}
|
||||
|
||||
async checkForDeviceType(): Promise<void> {
|
||||
const xrSupported = await this.isXRDevice();
|
||||
|
||||
if (xrSupported) {
|
||||
this.isXR = true;
|
||||
} else {
|
||||
console.log("DeviceUtilities.isMobileDevice()", DeviceUtilities.isMobileDevice());
|
||||
this.isMobile = DeviceUtilities.isMobileDevice();
|
||||
|
||||
if (!this.isMobile) {
|
||||
this.isDesktop = DeviceUtilities.isDesktop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async isXRDevice(): Promise<boolean> {
|
||||
if (navigator.xr) {
|
||||
try {
|
||||
return await navigator.xr.isSessionSupported("immersive-vr");
|
||||
} catch {
|
||||
console.log("XR check error!");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
setupMobileControls(): void {
|
||||
if (typeof document !== "undefined" && document.querySelector("#asset-picker")) {
|
||||
return;
|
||||
}
|
||||
this.createMenuMobileControls();
|
||||
}
|
||||
|
||||
setupDesktopControls(): void {
|
||||
// Optional: mirror mobile controls on desktop
|
||||
}
|
||||
|
||||
createMenuMobileControls(): void {
|
||||
const menuControlsContainer = document.createElement("div");
|
||||
menuControlsContainer.id = "menuControlsZone";
|
||||
menuControlsContainer.style.cssText = `
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
bottom: 10%;
|
||||
left: 10%;
|
||||
right: 10%;
|
||||
height: 150px;
|
||||
`;
|
||||
|
||||
document.body.appendChild(menuControlsContainer);
|
||||
|
||||
const previousButton = document.createElement("button");
|
||||
previousButton.id = "previousButton";
|
||||
previousButton.style.cssText = `
|
||||
height: 100px;
|
||||
width: 100px;
|
||||
background-color: #ffffff;
|
||||
color: #111111;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: manipulation;
|
||||
`;
|
||||
previousButton.setAttribute("aria-label", "Previous");
|
||||
previousButton.innerHTML = `
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 18L9 12L15 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`;
|
||||
previousButton.onclick = this.selectPreviousDish.bind(this);
|
||||
|
||||
const nextButton = document.createElement("button");
|
||||
nextButton.id = "nextButton";
|
||||
nextButton.style.cssText = `
|
||||
height: 100px;
|
||||
width: 100px;
|
||||
background-color: #ffffff;
|
||||
color: #111111;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: manipulation;
|
||||
`;
|
||||
nextButton.setAttribute("aria-label", "Next");
|
||||
nextButton.innerHTML = `
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 6L15 12L9 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`;
|
||||
nextButton.onclick = this.selectNextDish.bind(this);
|
||||
|
||||
if (this.dishName) {
|
||||
previousButton.disabled = true;
|
||||
previousButton.style.display = "none";
|
||||
nextButton.disabled = true;
|
||||
nextButton.style.display = "none";
|
||||
}
|
||||
|
||||
menuControlsContainer.appendChild(previousButton);
|
||||
menuControlsContainer.appendChild(nextButton);
|
||||
}
|
||||
|
||||
private disableDoubleTapZoom(): void {
|
||||
let lastTouchEnd = 0;
|
||||
const onTouchEnd = (event: TouchEvent): void => {
|
||||
const now = Date.now();
|
||||
if (now - lastTouchEnd <= 300) {
|
||||
event.preventDefault();
|
||||
}
|
||||
lastTouchEnd = now;
|
||||
};
|
||||
document.addEventListener("touchend", onTouchEnd, { passive: false });
|
||||
}
|
||||
|
||||
private getValidDishIndices(): number[] {
|
||||
return this.dishes.map((dish, index) => (dish != null ? index : -1)).filter((index) => index >= 0);
|
||||
}
|
||||
|
||||
/** Show exactly one dish: current {@link selectedDishIndex}, or the first valid slot if the index is unset. */
|
||||
private ensureOnlySelectedDishVisible(): void {
|
||||
const valid = this.getValidDishIndices();
|
||||
if (valid.length === 0) return;
|
||||
|
||||
let idx = this.selectedDishIndex;
|
||||
if (valid.indexOf(idx) < 0) {
|
||||
idx = valid[0];
|
||||
this.selectedDishIndex = idx;
|
||||
}
|
||||
|
||||
valid.forEach((i) => {
|
||||
const active = i === this.selectedDishIndex;
|
||||
const d = this.dishes[i];
|
||||
if (!active) {
|
||||
this.restoreDishBaseY(d);
|
||||
}
|
||||
GameObject.setActive(this.dishes[i], active);
|
||||
});
|
||||
}
|
||||
|
||||
private restoreDishBaseY(dish: Object3D | null | undefined): void {
|
||||
if (!dish) return;
|
||||
const y = dishBaseY.get(dish);
|
||||
if (y !== undefined) {
|
||||
dish.position.y = y;
|
||||
}
|
||||
}
|
||||
|
||||
private snapActiveDishToBaseY(): void {
|
||||
this.restoreDishBaseY(this.dishes[this.selectedDishIndex]);
|
||||
}
|
||||
|
||||
/** For HTML overlay: how many dish slots are assigned in Unity. */
|
||||
getDishSlotCount(): number {
|
||||
return this.getValidDishIndices().length;
|
||||
}
|
||||
|
||||
/** Label for the asset picker (object name from Unity when set, else Dish i / n). */
|
||||
getPickerLabel(): string {
|
||||
const valid = this.getValidDishIndices();
|
||||
if (valid.length === 0) return "Menu";
|
||||
const pos = Math.max(0, valid.indexOf(this.selectedDishIndex));
|
||||
const dish = this.dishes[this.selectedDishIndex];
|
||||
const label = dish?.name?.trim();
|
||||
if (label) {
|
||||
return `${label} (${pos + 1}/${valid.length})`;
|
||||
}
|
||||
return `Dish ${pos + 1} / ${valid.length}`;
|
||||
}
|
||||
|
||||
selectPreviousDish(): void {
|
||||
const valid = this.getValidDishIndices();
|
||||
if (valid.length === 0) return;
|
||||
|
||||
let pos = valid.indexOf(this.selectedDishIndex);
|
||||
if (pos < 0) pos = 0;
|
||||
|
||||
this.restoreDishBaseY(this.dishes[valid[pos]]);
|
||||
GameObject.setActive(this.dishes[valid[pos]], false);
|
||||
pos = (pos - 1 + valid.length) % valid.length;
|
||||
this.selectedDishIndex = valid[pos];
|
||||
GameObject.setActive(this.dishes[this.selectedDishIndex], true);
|
||||
|
||||
this.updateUSDZExporterTarget();
|
||||
}
|
||||
|
||||
selectNextDish(): void {
|
||||
const valid = this.getValidDishIndices();
|
||||
if (valid.length === 0) return;
|
||||
|
||||
let pos = valid.indexOf(this.selectedDishIndex);
|
||||
if (pos < 0) pos = 0;
|
||||
|
||||
this.restoreDishBaseY(this.dishes[valid[pos]]);
|
||||
GameObject.setActive(this.dishes[valid[pos]], false);
|
||||
pos = (pos + 1) % valid.length;
|
||||
this.selectedDishIndex = valid[pos];
|
||||
GameObject.setActive(this.dishes[this.selectedDishIndex], true);
|
||||
|
||||
this.updateUSDZExporterTarget();
|
||||
}
|
||||
|
||||
private updateUSDZExporterTarget(): void {
|
||||
const dish = this.dishes[this.selectedDishIndex];
|
||||
if (this.usdzExporter && dish) {
|
||||
this.usdzExporter.objectToExport = dish;
|
||||
}
|
||||
}
|
||||
|
||||
getUrlParameter(name: string): string | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get(name);
|
||||
}
|
||||
}
|
||||
30
Needle/MenuScene/src/scripts/MyComponent.ts
Normal file
30
Needle/MenuScene/src/scripts/MyComponent.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
// 1) Uncomment the code below to get started with your own script!
|
||||
// 2) You can then return to your editor and add the 'MyComponent' component to any object in your scene.
|
||||
// 3) Click Export or Play and see the effect in the browser. You've successfully added your first Needle Engine component to your 3D scene
|
||||
// 4) Continue learning on https://docs.needle.tools/scripting
|
||||
|
||||
|
||||
// import { Behaviour, Gizmos, serializable, showBalloonMessage } from "@needle-tools/engine";
|
||||
// import { Object3D } from "three";
|
||||
|
||||
// export class MyComponent extends Behaviour {
|
||||
|
||||
// @serializable()
|
||||
// rotationSpeed: number = 1;
|
||||
|
||||
// @serializable(Object3D)
|
||||
// otherObject: Object3D | null = null;
|
||||
|
||||
// start() {
|
||||
// showBalloonMessage("Hello Needle");
|
||||
// console.log("Hello Needle - this component is on the " + this.gameObject.name + " object");
|
||||
// }
|
||||
|
||||
// update(): void {
|
||||
// Gizmos.DrawWireSphere(this.gameObject.worldPosition, .5, 0xddff33);
|
||||
// if (this.otherObject) this.otherObject.rotateY(this.context.time.deltaTime * this.rotationSpeed);
|
||||
// else this.gameObject.rotateY(this.context.time.deltaTime * this.rotationSpeed);
|
||||
// }
|
||||
|
||||
// }
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Behaviour, BloomEffect, serializable, Volume } from "@needle-tools/engine";
|
||||
|
||||
export class PostProcessingVolumeController extends Behaviour {
|
||||
@serializable(Volume)
|
||||
volume?: Volume;
|
||||
|
||||
start(): void {
|
||||
if (!this.volume) {
|
||||
console.warn("No PostProcessVolume assigned");
|
||||
return;
|
||||
}
|
||||
|
||||
this.volume.addEffect(
|
||||
new BloomEffect({
|
||||
intensity: 3,
|
||||
luminanceThreshold: 0.2,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
7
Needle/MenuScene/src/scripts/Readme.md
Normal file
7
Needle/MenuScene/src/scripts/Readme.md
Normal file
@@ -0,0 +1,7 @@
|
||||
Project-specific typescript files go here.
|
||||
Needle Engine will automatically generate matching C# "stub components" so you can attach them to objects in Unity.
|
||||
|
||||
If you want to reuse components between multiple projects, a great way to do so are NpmDefs – reusable modules that contain both TypeScript and C# components.
|
||||
|
||||
Learn more about scripting on the docs:
|
||||
https://docs.needle.tools/scripting
|
||||
90
Needle/MenuScene/src/styles/style.css
Normal file
90
Needle/MenuScene/src/styles/style.css
Normal file
@@ -0,0 +1,90 @@
|
||||
html {
|
||||
height: -webkit-fill-available;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
needle-engine {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Sit above Needle Engine’s bottom menu / controls (DOM overlay) */
|
||||
#asset-picker {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 600;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#asset-picker .asset-picker__inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: min(100%, 36rem);
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 15, 20, 0.82);
|
||||
color: #f2f2f7;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-size: 0.95rem;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
#asset-picker button {
|
||||
pointer-events: auto;
|
||||
touch-action: manipulation;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
color: #0f0f14;
|
||||
background: #e8e8ed;
|
||||
}
|
||||
|
||||
#asset-picker button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#asset-picker #asset-picker-ar {
|
||||
background: #34c759;
|
||||
color: #0a0a0c;
|
||||
}
|
||||
|
||||
#asset-picker #asset-picker-ar:disabled {
|
||||
background: #3a3a3e;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
#asset-picker .asset-picker__label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#asset-picker .asset-picker__index {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.85;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
Reference in New Issue
Block a user