scratchpad()
is a very powerful utility. It allows you to easily attach a JSX function component to an Unity Editor Tab/Window.
Usage
double-pads.tsx
import { h } from "preact"
import { signal } from "preact/signals"
import { scratchpad } from "onejs-editor"
const counter = signal(1)
scratchpad("Pad 1", () => {
return <div class="w-full h-full items-center justify-center bg-red-500">
<div class="text-5xl text-white text-center p-16">OneJS now works for custom Editors 🍺</div>
<button class="text-2xl p-3" onClick={() => counter.value++}>Increment</button>
</div>
})
scratchpad("Pad 2", () => {
return <div class="w-full h-full items-center justify-center bg-blue-500 text-8xl">
<div>{`${counter.value}`}</div>
<div>🎂</div>
</div>
})
As you can see, scratchpad()
is super easy to use. Preact, Tailwind, Signals, LiveReload and the rest all work just like you’d expect. Use this to quickly prototype ideas, test out Unity APIs, or just have fun with JavaScript in the Editor!
Sources
In case you are interested in the source code of the shown examples, here they are.
Click for balls-drop.tsx source
balls-drop.tsx
import { h } from "preact"
import { vec2 } from "onejs/math"
import { Color } from "UnityEngine"
import { useEffect, useRef, useState } from "preact/hooks"
import { Angle, MeshGenerationContext, PointerMoveEvent } from "UnityEngine/UIElements"
export const Ballsdrop = () => {
const ref = useRef<Element>()
const ballsRef = useRef<any[]>([])
const animationRef = useRef<any>(null)
const mouseRef = useRef<any>({ x: null, y: null })
const [ballCount, setBallCount] = useState(0)
useEffect(() => {
animate()
ref.current!.ve.generateVisualContent = onGenerateVisualContent
ref.current!.ve.MarkDirtyRepaint()
return () => {
cancelAnimationFrame(animationRef.current)
}
}, [])
function addBalls() {
const { width } = ref.current!.ve.resolvedStyle
for (let i = 0; i < 15; i++) {
const size = Math.random() * 15 + 10
ballsRef.current.push({
x: Math.random() * width,
y: size, // Start at top
size,
vx: Math.random() * 3 - 1.5,
vy: Math.random() * 2 + 2, // Downward velocity
hue: Math.random() * 360
})
}
setBallCount(ballsRef.current.length)
}
function onGenerateVisualContent(mgc: MeshGenerationContext) {
var painter2D = mgc.painter2D
// Draw balls
ballsRef.current.forEach(ball => {
painter2D.fillColor = Color.HSVToRGB(ball.hue / 360, 0.5, 1)
painter2D.BeginPath()
painter2D.Arc(vec2(ball.x, ball.y), ball.size, new Angle(0), new Angle(360))
painter2D.Fill()
painter2D.ClosePath()
})
}
function pointerMove(e: PointerMoveEvent) {
mouseRef.current.x = e.position.x
mouseRef.current.y = e.position.y
ref.current!.ve.MarkDirtyRepaint()
}
function pointerLeave() {
mouseRef.current.x = null
mouseRef.current.y = null
}
function handleCollisions() {
const balls = ballsRef.current
const len = balls.length
for (let i = 0; i < len; i++) {
for (let j = i + 1; j < len; j++) {
const b1 = balls[i]
const b2 = balls[j]
// Calculate distance between ball centers
const dx = b2.x - b1.x
const dy = b2.y - b1.y
const distance = Math.sqrt(dx * dx + dy * dy)
// Combined radius (sum of both ball radii)
const minDistance = b1.size + b2.size
// Check for collision
if (distance < minDistance) {
// Collision detected - calculate response
// Normal vector in direction of collision
const nx = dx / distance
const ny = dy / distance
// Calculate relative velocity
const vx = b2.vx - b1.vx
const vy = b2.vy - b1.vy
// Relative velocity along normal
const velAlongNormal = vx * nx + vy * ny
// Don't resolve if balls are moving away from each other
if (velAlongNormal > 0) continue
// Simple elastic collision formula
const restitution = 0.85 // Bounciness factor
// Impulse scalar
const impulse = -(1 + restitution) * velAlongNormal / 2
// Apply impulse to velocities
b1.vx -= impulse * nx
b1.vy -= impulse * ny
b2.vx += impulse * nx
b2.vy += impulse * ny
// Move balls apart to prevent sticking
const overlap = minDistance - distance
const moveX = nx * overlap * 0.5
const moveY = ny * overlap * 0.5
b1.x -= moveX
b1.y -= moveY
b2.x += moveX
b2.y += moveY
}
}
}
}
function animate() {
const { width, height } = ref.current!.ve.resolvedStyle
const balls = ballsRef.current
// Apply gravity
const gravity = 0.2
balls.forEach(ball => {
// Update position
ball.x += ball.vx
ball.y += ball.vy
// Apply gravity
ball.vy += gravity
// Apply friction
ball.vx *= 0.995
ball.vy *= 0.995
// Boundary check with bounce
if (ball.x - ball.size < 0) {
ball.x = ball.size
ball.vx *= -0.9 // Bounce with slight energy loss
} else if (ball.x + ball.size > width) {
ball.x = width - ball.size
ball.vx *= -0.9
}
if (ball.y - ball.size < 0) {
ball.y = ball.size
ball.vy *= -0.9
} else if (ball.y + ball.size > height) {
ball.y = height - ball.size
ball.vy *= -0.9
}
// Slowly change color
ball.hue = (ball.hue + 0.2) % 360
})
// Handle ball-to-ball collisions
handleCollisions()
animationRef.current = requestAnimationFrame(animate)
ref.current!.ve.MarkDirtyRepaint()
}
return (
<div
ref={ref}
class="w-full h-full relative"
onPointerMove={pointerMove}
onPointerLeave={pointerLeave}
>
<button
class="absolute top-5 right-5 px-4 py-2 bg-blue-500 text-white border-0 rounded cursor-pointer font-bold"
onClick={addBalls}
>
Add Balls
</button>
<div class="absolute top-5 left-5 text-white font-bold xx">
{`Count: ${ballCount}`}
</div>
</div>
)
}
Click for magnetic-particles.tsx source
magnetic-particles.tsx
import { h } from "preact"
import { vec2 } from "onejs/math"
import { Color } from "UnityEngine"
import { useEffect, useRef } from "preact/hooks"
import { Angle, MeshGenerationContext, PointerMoveEvent } from "UnityEngine/UIElements"
export const MagneticParticles = ({ window }) => {
const ref = useRef<Element>()
const particlesRef = useRef<any[]>([])
const animationRef = useRef<any>(null)
const mouseRef = useRef<any>({ x: null, y: null });
useEffect(() => {
const { width, height } = window.rootVisualElement.resolvedStyle
const particleCount = Math.floor((width * height) / 15000);
for (let i = 0; i < particleCount; i++) {
particlesRef.current.push({
x: Math.random() * width,
y: Math.random() * height,
size: Math.random() * 20 + 1,
vx: Math.random() * 2 - 1,
vy: Math.random() * 2 - 1,
hue: Math.random() * 360
});
}
animate()
ref.current!.ve.generateVisualContent = onGenerateVisualContent
ref.current!.ve.MarkDirtyRepaint()
return () => {
cancelAnimationFrame(animationRef.current)
}
}, [])
function onGenerateVisualContent(mgc: MeshGenerationContext) {
var painter2D = mgc.painter2D
particlesRef.current.forEach(p => {
painter2D.fillColor = Color.HSVToRGB(p.hue / 360, 0.5, 1)
painter2D.BeginPath()
painter2D.Arc(vec2(p.x, p.y), p.size, new Angle(0), new Angle(360))
painter2D.Fill()
painter2D.ClosePath()
})
}
function pointerMove(e: PointerMoveEvent) {
mouseRef.current.x = e.position.x
mouseRef.current.y = e.position.y
ref.current!.ve.MarkDirtyRepaint()
}
function pointerLeave() {
mouseRef.current.x = null
mouseRef.current.y = null
}
function animate() {
const { width, height } = ref.current!.ve.resolvedStyle
particlesRef.current.forEach(p => {
// Update position
p.x += p.vx;
p.y += p.vy;
// Apply friction
p.vx *= 0.95;
p.vy *= 0.95;
// Mouse attraction
if (mouseRef.current.x && mouseRef.current.y) {
const dx = mouseRef.current.x - p.x;
const dy = mouseRef.current.y - p.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 150) {
const angle = Math.atan2(dy, dx);
const force = (150 - dist) / 100;
p.vx += Math.cos(angle) * force * 0.6;
p.vy += Math.sin(angle) * force * 0.6;
}
}
// Boundary check
if (p.x < 0 || p.x > width) p.vx *= -1;
if (p.y < 0 || p.y > height) p.vy *= -1;
// Slowly change color
p.hue = (p.hue + 0.2) % 360;
});
animationRef.current = requestAnimationFrame(animate)
ref.current!.ve.MarkDirtyRepaint()
}
return <div ref={ref} class="w-full h-full" onPointerMove={pointerMove} onPointerLeave={pointerLeave}></div>
}