playground now has a shared folder and a personal folder
This commit is contained in:
parent
1647d91dc5
commit
4d49a8bdd6
18 changed files with 39 additions and 383 deletions
137
packages/playground/src/shared/dom/Scene.tsx
Normal file
137
packages/playground/src/shared/dom/Scene.tsx
Normal file
|
@ -0,0 +1,137 @@
|
|||
import studio from '@theatre/studio'
|
||||
import type {UseDragOpts} from './useDrag'
|
||||
import useDrag from './useDrag'
|
||||
import React, {useLayoutEffect, useMemo, useState} from 'react'
|
||||
import type {IProject, ISheet} from '@theatre/core'
|
||||
import {onChange} from '@theatre/core'
|
||||
import type {IScrub, IStudio} from '@theatre/studio'
|
||||
|
||||
studio.initialize()
|
||||
|
||||
const boxObjectConfig = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
}
|
||||
|
||||
const Box: React.FC<{
|
||||
id: string
|
||||
sheet: ISheet
|
||||
selection: IStudio['selection']
|
||||
}> = ({id, sheet, selection}) => {
|
||||
// This is cheap to call and always returns the same value, so no need for useMemo()
|
||||
const obj = sheet.object(id, boxObjectConfig)
|
||||
|
||||
const isSelected = selection.includes(obj)
|
||||
|
||||
const [pos, setPos] = useState<{x: number; y: number}>({x: 0, y: 0})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const unsubscribeFromChanges = onChange(obj.props, (newValues) => {
|
||||
setPos(newValues)
|
||||
})
|
||||
return unsubscribeFromChanges
|
||||
}, [id])
|
||||
|
||||
const [divRef, setDivRef] = useState<HTMLElement | null>(null)
|
||||
|
||||
const dragOpts = useMemo((): UseDragOpts => {
|
||||
let scrub: IScrub | undefined
|
||||
let initial: typeof obj.value
|
||||
let firstOnDragCalled = false
|
||||
return {
|
||||
onDragStart() {
|
||||
scrub = studio.scrub()
|
||||
initial = obj.value
|
||||
firstOnDragCalled = false
|
||||
},
|
||||
onDrag(x, y) {
|
||||
if (!firstOnDragCalled) {
|
||||
studio.setSelection([obj])
|
||||
firstOnDragCalled = true
|
||||
}
|
||||
scrub!.capture(({set}) => {
|
||||
set(obj.props, {x: x + initial.x, y: y + initial.y})
|
||||
})
|
||||
},
|
||||
onDragEnd(dragHappened) {
|
||||
if (dragHappened) {
|
||||
scrub!.commit()
|
||||
} else {
|
||||
scrub!.discard()
|
||||
}
|
||||
},
|
||||
lockCursorTo: 'move',
|
||||
}
|
||||
}, [])
|
||||
|
||||
useDrag(divRef, dragOpts)
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
studio.setSelection([obj])
|
||||
}}
|
||||
ref={setDivRef}
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
background: 'gray',
|
||||
position: 'absolute',
|
||||
left: pos.x + 'px',
|
||||
top: pos.y + 'px',
|
||||
boxSizing: 'border-box',
|
||||
border: isSelected ? '1px solid #5a92fa' : '1px solid transparent',
|
||||
}}
|
||||
></div>
|
||||
)
|
||||
}
|
||||
|
||||
let lastBoxId = 1
|
||||
|
||||
export const Scene: React.FC<{project: IProject}> = ({project}) => {
|
||||
const [boxes, setBoxes] = useState<Array<string>>(['0', '1'])
|
||||
|
||||
// This is cheap to call and always returns the same value, so no need for useMemo()
|
||||
const sheet = project.sheet('Scene', 'default')
|
||||
const [selection, setSelection] = useState<IStudio['selection']>()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return studio.onSelectionChange((newState) => {
|
||||
setSelection(newState)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
right: '0',
|
||||
top: 0,
|
||||
bottom: '0',
|
||||
background: 'black',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
style={{
|
||||
top: '16px',
|
||||
left: '60px',
|
||||
position: 'absolute',
|
||||
}}
|
||||
onClick={() => {
|
||||
setBoxes((boxes) => [...boxes, String(++lastBoxId)])
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
{boxes.map((id) => (
|
||||
<Box
|
||||
key={'box' + id}
|
||||
id={id}
|
||||
sheet={sheet}
|
||||
selection={selection ?? []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
18
packages/playground/src/shared/dom/index.tsx
Normal file
18
packages/playground/src/shared/dom/index.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import studio from '@theatre/studio'
|
||||
import {getProject} from '@theatre/core'
|
||||
import {Scene} from './Scene'
|
||||
/**
|
||||
* This is a basic example of using Theatre for manipulating the DOM.
|
||||
*
|
||||
* It also uses {@link IStudio.selection | studio.selection} to customize
|
||||
* the selection behavior.
|
||||
*/
|
||||
|
||||
studio.initialize()
|
||||
|
||||
ReactDOM.render(
|
||||
<Scene project={getProject('Sample project')} />,
|
||||
document.getElementById('root'),
|
||||
)
|
150
packages/playground/src/shared/dom/useDrag.ts
Normal file
150
packages/playground/src/shared/dom/useDrag.ts
Normal file
|
@ -0,0 +1,150 @@
|
|||
import {useLayoutEffect, useRef} from 'react'
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
function createCursorLock(cursor: string) {
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 9999999;`
|
||||
|
||||
el.style.cursor = cursor
|
||||
document.body.appendChild(el)
|
||||
const relinquish = () => {
|
||||
document.body.removeChild(el)
|
||||
}
|
||||
|
||||
return relinquish
|
||||
}
|
||||
|
||||
export type UseDragOpts = {
|
||||
disabled?: boolean
|
||||
dontBlockMouseDown?: boolean
|
||||
lockCursorTo?: string
|
||||
onDragStart?: (event: MouseEvent) => void | false
|
||||
onDragEnd?: (dragHappened: boolean) => void
|
||||
onDrag: (dx: number, dy: number, event: MouseEvent) => void
|
||||
}
|
||||
|
||||
export default function useDrag(
|
||||
target: HTMLElement | undefined | null,
|
||||
opts: UseDragOpts,
|
||||
) {
|
||||
const optsRef = useRef<typeof opts>(opts)
|
||||
optsRef.current = opts
|
||||
|
||||
const modeRef = useRef<'dragStartCalled' | 'dragging' | 'notDragging'>(
|
||||
'notDragging',
|
||||
)
|
||||
|
||||
const stateRef = useRef<{
|
||||
dragHappened: boolean
|
||||
startPos: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
}>({dragHappened: false, startPos: {x: 0, y: 0}})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!target) return
|
||||
|
||||
const getDistances = (event: MouseEvent): [number, number] => {
|
||||
const {startPos} = stateRef.current
|
||||
return [event.screenX - startPos.x, event.screenY - startPos.y]
|
||||
}
|
||||
|
||||
let relinquishCursorLock = noop
|
||||
|
||||
const dragHandler = (event: MouseEvent) => {
|
||||
if (!stateRef.current.dragHappened && optsRef.current.lockCursorTo) {
|
||||
relinquishCursorLock = createCursorLock(optsRef.current.lockCursorTo)
|
||||
}
|
||||
if (!stateRef.current.dragHappened) stateRef.current.dragHappened = true
|
||||
modeRef.current = 'dragging'
|
||||
|
||||
const deltas = getDistances(event)
|
||||
optsRef.current.onDrag(deltas[0], deltas[1], event)
|
||||
}
|
||||
|
||||
const dragEndHandler = () => {
|
||||
removeDragListeners()
|
||||
modeRef.current = 'notDragging'
|
||||
|
||||
optsRef.current.onDragEnd &&
|
||||
optsRef.current.onDragEnd(stateRef.current.dragHappened)
|
||||
relinquishCursorLock()
|
||||
relinquishCursorLock = noop
|
||||
}
|
||||
|
||||
const addDragListeners = () => {
|
||||
document.addEventListener('mousemove', dragHandler)
|
||||
document.addEventListener('mouseup', dragEndHandler)
|
||||
}
|
||||
|
||||
const removeDragListeners = () => {
|
||||
document.removeEventListener('mousemove', dragHandler)
|
||||
document.removeEventListener('mouseup', dragEndHandler)
|
||||
}
|
||||
|
||||
const preventUnwantedClick = (event: MouseEvent) => {
|
||||
if (optsRef.current.disabled) return
|
||||
if (stateRef.current.dragHappened) {
|
||||
if (
|
||||
!optsRef.current.dontBlockMouseDown &&
|
||||
modeRef.current !== 'notDragging'
|
||||
) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
}
|
||||
stateRef.current.dragHappened = false
|
||||
}
|
||||
}
|
||||
|
||||
const dragStartHandler = (event: MouseEvent) => {
|
||||
const opts = optsRef.current
|
||||
if (opts.disabled === true) return
|
||||
|
||||
if (event.button !== 0) return
|
||||
const resultOfStart = opts.onDragStart && opts.onDragStart(event)
|
||||
|
||||
if (resultOfStart === false) return
|
||||
|
||||
if (!opts.dontBlockMouseDown) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
modeRef.current = 'dragStartCalled'
|
||||
|
||||
const {screenX, screenY} = event
|
||||
stateRef.current.startPos = {x: screenX, y: screenY}
|
||||
stateRef.current.dragHappened = false
|
||||
|
||||
addDragListeners()
|
||||
}
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
dragStartHandler(e)
|
||||
}
|
||||
|
||||
target.addEventListener('mousedown', onMouseDown)
|
||||
target.addEventListener('click', preventUnwantedClick)
|
||||
|
||||
return () => {
|
||||
removeDragListeners()
|
||||
target.removeEventListener('mousedown', onMouseDown)
|
||||
target.removeEventListener('click', preventUnwantedClick)
|
||||
relinquishCursorLock()
|
||||
|
||||
if (modeRef.current !== 'notDragging') {
|
||||
optsRef.current.onDragEnd &&
|
||||
optsRef.current.onDragEnd(modeRef.current === 'dragging')
|
||||
}
|
||||
modeRef.current = 'notDragging'
|
||||
}
|
||||
}, [target])
|
||||
}
|
112
packages/playground/src/shared/r3f-rocket/App.tsx
Normal file
112
packages/playground/src/shared/r3f-rocket/App.tsx
Normal file
|
@ -0,0 +1,112 @@
|
|||
import {
|
||||
editable as e,
|
||||
RefreshSnapshot,
|
||||
SheetProvider,
|
||||
extension,
|
||||
} from '@theatre/r3f'
|
||||
import {OrbitControls, Stars} from '@react-three/drei'
|
||||
import {getProject} from '@theatre/core'
|
||||
import React, {Suspense, useState} from 'react'
|
||||
import {Canvas} from '@react-three/fiber'
|
||||
import {useGLTF} from '@react-three/drei'
|
||||
import sceneGLB from './scene.glb'
|
||||
import studio from '@theatre/studio'
|
||||
|
||||
studio.extend(extension)
|
||||
studio.initialize()
|
||||
|
||||
document.body.style.backgroundColor = '#171717'
|
||||
|
||||
/*
|
||||
Auto-generated by: https://github.com/pmndrs/gltfjsx
|
||||
author: overlaps (https://sketchfab.com/overlaps)
|
||||
license: CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/)
|
||||
source: https://sketchfab.com/models/91964c1ce1a34c3985b6257441efa500
|
||||
title: Space exploration [WLP series #8]
|
||||
*/
|
||||
function Model({url}: {url: string}) {
|
||||
const {nodes} = useGLTF(url) as any
|
||||
|
||||
return (
|
||||
<group rotation={[-Math.PI / 2, 0, 0]} position={[0, -7, 0]} scale={7}>
|
||||
<group rotation={[Math.PI / 13.5, -Math.PI / 5.8, Math.PI / 5.6]}>
|
||||
<e.mesh
|
||||
uniqueName="Thingy"
|
||||
receiveShadow
|
||||
castShadow
|
||||
geometry={nodes.planet001.geometry}
|
||||
material={nodes.planet001.material}
|
||||
/>
|
||||
<e.mesh
|
||||
uniqueName="Debris 2"
|
||||
receiveShadow
|
||||
castShadow
|
||||
geometry={nodes.planet002.geometry}
|
||||
material={nodes.planet002.material}
|
||||
/>
|
||||
<e.mesh
|
||||
uniqueName="Debris 1"
|
||||
geometry={nodes.planet003.geometry}
|
||||
material={nodes.planet003.material}
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const bgs = ['#272730', '#b7c5d1']
|
||||
const [bgIndex, setBgIndex] = useState(0)
|
||||
const bg = bgs[bgIndex]
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
// return setBgIndex((bgIndex) => (bgIndex + 1) % bgs.length)
|
||||
}}
|
||||
>
|
||||
<Canvas dpr={[1.5, 2]} linear shadows frameloop="demand">
|
||||
<SheetProvider getSheet={() => getProject('Space').sheet('Scene')}>
|
||||
<fog attach="fog" args={[bg, 16, 30]} />
|
||||
<color attach="background" args={[bg]} />
|
||||
<ambientLight intensity={0.75} />
|
||||
<e.perspectiveCamera
|
||||
uniqueName="Camera"
|
||||
// @ts-ignore
|
||||
makeDefault
|
||||
position={[0, 0, 16]}
|
||||
fov={75}
|
||||
>
|
||||
<e.pointLight
|
||||
uniqueName="Light 1"
|
||||
intensity={1}
|
||||
position={[-10, -25, -10]}
|
||||
/>
|
||||
<e.spotLight
|
||||
uniqueName="Light 2"
|
||||
castShadow
|
||||
intensity={2.25}
|
||||
angle={0.2}
|
||||
penumbra={1}
|
||||
position={[-25, 20, -15]}
|
||||
shadow-mapSize={[1024, 1024]}
|
||||
shadow-bias={-0.0001}
|
||||
/>
|
||||
</e.perspectiveCamera>
|
||||
<Suspense fallback={null}>
|
||||
<RefreshSnapshot />
|
||||
<Model url={sceneGLB} />
|
||||
</Suspense>
|
||||
<OrbitControls
|
||||
enablePan={false}
|
||||
enableZoom={true}
|
||||
maxPolarAngle={Math.PI / 2}
|
||||
minPolarAngle={Math.PI / 2}
|
||||
/>
|
||||
<Stars radius={500} depth={50} count={1000} factor={10} />
|
||||
</SheetProvider>
|
||||
</Canvas>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
5
packages/playground/src/shared/r3f-rocket/index.tsx
Normal file
5
packages/playground/src/shared/r3f-rocket/index.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('root'))
|
BIN
packages/playground/src/shared/r3f-rocket/scene.glb
Normal file
BIN
packages/playground/src/shared/r3f-rocket/scene.glb
Normal file
Binary file not shown.
152
packages/playground/src/shared/turtle/TurtleRenderer.tsx
Normal file
152
packages/playground/src/shared/turtle/TurtleRenderer.tsx
Normal file
|
@ -0,0 +1,152 @@
|
|||
import type {MutableRefObject} from 'react'
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import studio from '@theatre/studio'
|
||||
import type {ISheet} from '@theatre/core'
|
||||
import {types} from '@theatre/core'
|
||||
import type {ITurtle} from './turtle'
|
||||
import {drawTurtlePlan, makeTurtlePlan} from './turtle'
|
||||
|
||||
studio.initialize()
|
||||
|
||||
const objConfig = {
|
||||
startingPoint: {
|
||||
x: types.number(0.5, {range: [0, 1]}),
|
||||
y: types.number(0.5, {range: [0, 1]}),
|
||||
},
|
||||
scale: types.number(1, {range: [0.1, 1000]}),
|
||||
}
|
||||
|
||||
const TurtleRenderer: React.FC<{
|
||||
sheet: ISheet
|
||||
objKey: string
|
||||
width: number
|
||||
height: number
|
||||
programFn: (t: ITurtle) => void
|
||||
}> = (props) => {
|
||||
const [canvas, setCanvas] = useState<HTMLCanvasElement | null>(null)
|
||||
|
||||
const context = useMemo(() => {
|
||||
if (canvas) {
|
||||
return canvas.getContext('2d')!
|
||||
}
|
||||
}, [canvas])
|
||||
|
||||
const dimsRef = useRef({width: props.width, height: props.height})
|
||||
dimsRef.current = {width: props.width, height: props.height}
|
||||
|
||||
const obj = useMemo(() => {
|
||||
return props.sheet.object(props.objKey, objConfig)
|
||||
}, [props.sheet, props.objKey])
|
||||
|
||||
useEffect(() => {
|
||||
obj.onValuesChange((v) => {
|
||||
setTransforms(v)
|
||||
})
|
||||
}, [obj])
|
||||
|
||||
const [transforms, transformsRef, setTransforms] = useStateAndRef<
|
||||
typeof obj.value
|
||||
>({scale: 1, startingPoint: {x: 0.5, y: 0.5}})
|
||||
|
||||
const bounds = useMemo(() => canvas?.getBoundingClientRect(), [canvas])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!canvas) return
|
||||
|
||||
const receiveWheelEvent = (event: WheelEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const oldTransform = transformsRef.current
|
||||
const newTransform: typeof oldTransform = {
|
||||
...oldTransform,
|
||||
startingPoint: {...oldTransform.startingPoint},
|
||||
}
|
||||
|
||||
if (event.ctrlKey) {
|
||||
const scaleFactor = 1 - (event.deltaY / dimsRef.current.height) * 1.2
|
||||
newTransform.scale *= scaleFactor
|
||||
|
||||
// const bounds = canvas.getBoundingClientRect()
|
||||
|
||||
const anchorPoint = {
|
||||
x: (event.clientX - bounds!.left) / dimsRef.current.width,
|
||||
y: (event.clientY - bounds!.top) / dimsRef.current.height,
|
||||
}
|
||||
|
||||
newTransform.startingPoint.x =
|
||||
anchorPoint.x -
|
||||
(anchorPoint.x - newTransform.startingPoint.x) * scaleFactor
|
||||
|
||||
newTransform.startingPoint.y =
|
||||
anchorPoint.y -
|
||||
(anchorPoint.y - newTransform.startingPoint.y) * scaleFactor
|
||||
} else {
|
||||
newTransform.startingPoint.x =
|
||||
oldTransform.startingPoint.x - event.deltaX / dimsRef.current.width
|
||||
newTransform.startingPoint.y =
|
||||
oldTransform.startingPoint.y - event.deltaY / dimsRef.current.height
|
||||
}
|
||||
studio.transaction((api) => {
|
||||
api.set(obj.props, newTransform)
|
||||
})
|
||||
// setTransforms(newTransform)
|
||||
}
|
||||
|
||||
const listenerOptions = {
|
||||
capture: true,
|
||||
passive: false,
|
||||
}
|
||||
canvas.addEventListener('wheel', receiveWheelEvent, listenerOptions)
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener('wheel', receiveWheelEvent, listenerOptions)
|
||||
}
|
||||
}, [canvas])
|
||||
|
||||
const plan = useMemo(() => makeTurtlePlan(props.programFn), [props.programFn])
|
||||
|
||||
useEffect(() => {
|
||||
if (!context) return
|
||||
|
||||
drawTurtlePlan(
|
||||
plan,
|
||||
context,
|
||||
{
|
||||
width: props.width,
|
||||
height: props.height,
|
||||
scale: transforms.scale,
|
||||
startFrom: {
|
||||
x: transforms.startingPoint.x * props.width,
|
||||
y: transforms.startingPoint.y * props.height,
|
||||
},
|
||||
},
|
||||
1,
|
||||
)
|
||||
}, [props.width, props.height, plan, context, transforms])
|
||||
|
||||
return (
|
||||
<canvas width={props.width} height={props.height} ref={setCanvas}></canvas>
|
||||
)
|
||||
}
|
||||
|
||||
function useStateAndRef<S>(
|
||||
initial: S,
|
||||
): [S, MutableRefObject<S>, (s: S) => void] {
|
||||
const [state, setState] = useState(initial)
|
||||
const stateRef = useRef(state)
|
||||
const set = useCallback((s: S) => {
|
||||
stateRef.current = s
|
||||
setState(s)
|
||||
}, [])
|
||||
|
||||
return [state, stateRef, set]
|
||||
}
|
||||
|
||||
export default TurtleRenderer
|
55
packages/playground/src/shared/turtle/index.tsx
Normal file
55
packages/playground/src/shared/turtle/index.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* A super basic Turtle geometry renderer hooked up to Theatre, so the parameters
|
||||
* can be tweaked and animated.
|
||||
*/
|
||||
import React, {useMemo, useState} from 'react'
|
||||
import {render} from 'react-dom'
|
||||
import {getProject} from '@theatre/core'
|
||||
import type {ITurtle} from './turtle'
|
||||
import TurtleRenderer from './TurtleRenderer'
|
||||
import {useBoundingClientRect} from './utils'
|
||||
|
||||
const project = getProject('Turtle Playground')
|
||||
|
||||
const sheet = project.sheet('Turtle', 'The only one')
|
||||
|
||||
const TurtleExample: React.FC<{}> = (props) => {
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null)
|
||||
const programFn = useMemo(() => {
|
||||
return ({forward, backward, left, right, repeat}: ITurtle) => {
|
||||
const steps = 10
|
||||
repeat(steps, () => {
|
||||
forward(steps * 2)
|
||||
right(360 / steps)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const bounds = useBoundingClientRect(container)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setContainer}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
right: '0',
|
||||
bottom: '0',
|
||||
left: '0',
|
||||
background: 'black',
|
||||
}}
|
||||
>
|
||||
{bounds && (
|
||||
<TurtleRenderer
|
||||
sheet={sheet}
|
||||
objKey="Renderer"
|
||||
width={bounds.width}
|
||||
height={bounds.height}
|
||||
programFn={programFn}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<TurtleExample />, document.getElementById('root'))
|
189
packages/playground/src/shared/turtle/turtle.ts
Normal file
189
packages/playground/src/shared/turtle/turtle.ts
Normal file
|
@ -0,0 +1,189 @@
|
|||
import clamp from 'lodash-es/clamp'
|
||||
|
||||
type Op_Move = {
|
||||
type: 'Move'
|
||||
amount: number
|
||||
angle: number
|
||||
penDown: boolean
|
||||
}
|
||||
|
||||
type Op_ModifyContext = {
|
||||
type: 'ContextModifier'
|
||||
fn: (ctx: CanvasRenderingContext2D) => void
|
||||
}
|
||||
|
||||
type Op = Op_ModifyContext | Op_Move
|
||||
|
||||
type IPlan = {
|
||||
totalTravel: number
|
||||
ops: Op[]
|
||||
}
|
||||
|
||||
export function makeTurtlePlan(fn: (turtle: Turtle) => void): IPlan {
|
||||
const plan: IPlan = {
|
||||
totalTravel: 0,
|
||||
ops: [],
|
||||
}
|
||||
|
||||
const turtle = new Turtle(plan)
|
||||
fn(turtle)
|
||||
return plan
|
||||
}
|
||||
|
||||
export function drawTurtlePlan(
|
||||
plan: IPlan,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
{
|
||||
width,
|
||||
height,
|
||||
scale,
|
||||
startFrom,
|
||||
}: {
|
||||
width: number
|
||||
height: number
|
||||
scale: number
|
||||
startFrom: {x: number; y: number}
|
||||
},
|
||||
tilProgression: number,
|
||||
): void {
|
||||
const {ops} = plan
|
||||
if (ops.length === 0) return
|
||||
|
||||
const targetDistance = clamp(tilProgression, 0, 1) * plan.totalTravel
|
||||
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
let traveledSoFar = 0
|
||||
let pos = {...startFrom}
|
||||
ctx.beginPath()
|
||||
ctx.lineWidth = 2
|
||||
ctx.strokeStyle = 'white'
|
||||
ctx.moveTo(pos.x, pos.y)
|
||||
|
||||
for (const op of ops) {
|
||||
if (traveledSoFar >= targetDistance) return
|
||||
|
||||
if (op.type === 'ContextModifier') {
|
||||
op.fn(ctx)
|
||||
} else {
|
||||
let amount = Math.abs(op.amount)
|
||||
const sign = op.amount < 0 ? -1 : 1
|
||||
const {angle} = op
|
||||
|
||||
const roomTilTarget = targetDistance - traveledSoFar
|
||||
|
||||
const distanceInThisStep = roomTilTarget < amount ? roomTilTarget : amount
|
||||
|
||||
traveledSoFar += distanceInThisStep
|
||||
|
||||
pos = move(pos, angle, distanceInThisStep * sign, scale, op.penDown, ctx)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function move(
|
||||
pointA: {x: number; y: number},
|
||||
_angle: number,
|
||||
amount: number,
|
||||
scale: number,
|
||||
penIsDown: boolean,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
): {x: number; y: number} {
|
||||
const angle = (_angle * Math.PI) / 180
|
||||
|
||||
const unrotatedTarget = {
|
||||
x: pointA.x + amount * scale,
|
||||
y: pointA.y,
|
||||
}
|
||||
|
||||
const pointB = {
|
||||
x:
|
||||
pointA.x +
|
||||
Math.cos(angle) * (unrotatedTarget.x - pointA.x) -
|
||||
Math.sin(angle) * (unrotatedTarget.y - pointA.y),
|
||||
y:
|
||||
pointA.y +
|
||||
Math.sin(angle) * (unrotatedTarget.x - pointA.x) -
|
||||
Math.sin(angle) * (unrotatedTarget.y - pointA.y),
|
||||
}
|
||||
|
||||
if (penIsDown) {
|
||||
ctx.lineTo(pointB.x, pointB.y)
|
||||
} else {
|
||||
ctx.moveTo(pointB.x, pointB.y)
|
||||
}
|
||||
|
||||
return pointB
|
||||
}
|
||||
|
||||
class Turtle {
|
||||
private _state = {
|
||||
penIsDown: true,
|
||||
angle: -90,
|
||||
}
|
||||
|
||||
constructor(private _plan: IPlan) {}
|
||||
|
||||
fn = (innerFn: () => void) => {
|
||||
return innerFn
|
||||
}
|
||||
|
||||
private _pushContextModifier(fn: (ctx: CanvasRenderingContext2D) => void) {
|
||||
this._plan.ops.push({type: 'ContextModifier', fn})
|
||||
}
|
||||
|
||||
press = (n: number) => {
|
||||
this._pushContextModifier((ctx) => {
|
||||
ctx.lineWidth = n
|
||||
})
|
||||
}
|
||||
|
||||
forward = (amount: number) => {
|
||||
this._plan.ops.push({
|
||||
type: 'Move',
|
||||
amount,
|
||||
penDown: this._state.penIsDown,
|
||||
angle: this._state.angle,
|
||||
})
|
||||
this._plan.totalTravel += Math.abs(amount)
|
||||
return this
|
||||
}
|
||||
|
||||
backward = (amount: number) => {
|
||||
return this.forward(amount)
|
||||
}
|
||||
|
||||
right = (deg: number) => {
|
||||
this._rotate(deg)
|
||||
return this
|
||||
}
|
||||
|
||||
left = (deg: number) => {
|
||||
this._rotate(-deg)
|
||||
return this
|
||||
}
|
||||
|
||||
private _rotate(deg: number) {
|
||||
this._state.angle += deg
|
||||
}
|
||||
|
||||
penup = () => {
|
||||
this._state.penIsDown = false
|
||||
return this
|
||||
}
|
||||
|
||||
pendown = () => {
|
||||
this._state.penIsDown = true
|
||||
return this
|
||||
}
|
||||
|
||||
repeat = (n: number, fn: (i: number) => void) => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
fn(i)
|
||||
}
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export type ITurtle = Turtle
|
19
packages/playground/src/shared/turtle/utils.ts
Normal file
19
packages/playground/src/shared/turtle/utils.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import {useLayoutEffect, useState} from 'react'
|
||||
|
||||
export function useBoundingClientRect(
|
||||
node: HTMLElement | null,
|
||||
): null | DOMRect {
|
||||
const [bounds, set] = useState<null | DOMRect>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (node) {
|
||||
set(node.getBoundingClientRect())
|
||||
}
|
||||
|
||||
return () => {
|
||||
set(null)
|
||||
}
|
||||
}, [node])
|
||||
|
||||
return bounds
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue