155 lines
4.2 KiB
JavaScript
155 lines
4.2 KiB
JavaScript
|
//@ts-check
|
||
|
|
||
|
import ReactDOM from 'https://esm.sh/react-dom'
|
||
|
import React from 'https://esm.sh/react'
|
||
|
import * as RTF from 'https://esm.sh/@react-three/fiber'
|
||
|
import { useGLTF, useAnimations, PerspectiveCamera } from 'https://esm.sh/@react-three/drei'
|
||
|
import {html} from 'https://esm.sh/htm/react'
|
||
|
|
||
|
/**@typedef {[x:number, y:number, z:number]} V3 */
|
||
|
/**
|
||
|
* @typedef {{Type:"click", Payload:number}} ActionClick
|
||
|
* @typedef {{Type:"round"}} ActionRound
|
||
|
* @typedef {ActionClick | ActionRound} Action
|
||
|
*/
|
||
|
/**
|
||
|
* @typedef {{Done:boolean, Pos:V3}} StateGoal
|
||
|
*/
|
||
|
/**
|
||
|
* @typedef {{Round:number, Done:boolean, Goals:Array<StateGoal>}} State
|
||
|
*/
|
||
|
/** @typedef {(inState:State, inAction:Action)=>void} Reducer */
|
||
|
/** @typedef {{Initial:State, Reducer:React.Reducer<State, Action>, Consume:()=>GameBinding}} Game */
|
||
|
/** @typedef {[State:State, Dispatcher:React.Dispatch<Action>]} GameBinding */
|
||
|
|
||
|
/** @type Game */
|
||
|
const Game =
|
||
|
{
|
||
|
Initial:
|
||
|
{
|
||
|
Round:0,
|
||
|
Goals:[{Done:false, Pos:[0, 0, 0]}, {Done:true, Pos:[2, 0, 0]}],
|
||
|
Done:false
|
||
|
},
|
||
|
Reducer(inState, inAction)
|
||
|
{
|
||
|
/**@type {(inGoals:Array<StateGoal>)=>boolean} */
|
||
|
const allDone =(inGoals)=>
|
||
|
{
|
||
|
for(let i=0; i<inGoals.length; i++)
|
||
|
{
|
||
|
if(!clone.Goals[i].Done)
|
||
|
{
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
return true;
|
||
|
}
|
||
|
/** @type State */ const clone = {...inState};
|
||
|
switch(inAction.Type)
|
||
|
{
|
||
|
case "click":
|
||
|
{
|
||
|
/**@type StateGoal|undefined*/
|
||
|
const goal = clone.Goals[inAction.Payload];
|
||
|
if(goal)
|
||
|
{
|
||
|
goal.Done = !goal.Done;
|
||
|
}
|
||
|
clone.Done = allDone(clone.Goals);
|
||
|
break;
|
||
|
}
|
||
|
case "round":
|
||
|
{
|
||
|
clone.Round++;
|
||
|
clone.Goals = [];
|
||
|
clone.Done = false;
|
||
|
for(let i=0; i<1+Math.random()*10; i++)
|
||
|
{
|
||
|
/**@type StateGoal*/ const goal = {Done:false, Pos:[
|
||
|
(Math.random()-0.5)*5,
|
||
|
(Math.random()-0.5)*5,
|
||
|
(Math.random()-0.5)*5
|
||
|
]};
|
||
|
clone.Goals.push(goal)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
return clone;
|
||
|
},
|
||
|
Consume()
|
||
|
{
|
||
|
return React.useReducer(Game.Reducer, Game.Initial);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
/** @type {(props:{goal:StateGoal, handler:()=>void})=>JSX.Element}*/
|
||
|
function Model(props)
|
||
|
{
|
||
|
const { nodes, animations } = useGLTF("/bounce.gltf");
|
||
|
const Anim = useAnimations(animations);
|
||
|
React.useEffect(()=>
|
||
|
{
|
||
|
const action = Anim.actions.CubeAction;
|
||
|
props.goal.Done ? action.play() : action.stop();
|
||
|
}, [props.goal.Done]);
|
||
|
|
||
|
return html`
|
||
|
<group position=${props.goal.Pos} dispose=${null}>
|
||
|
<mesh
|
||
|
ref=${Anim.ref}
|
||
|
name="Cube"
|
||
|
castShadow
|
||
|
receiveShadow
|
||
|
geometry=${nodes.Cube.geometry}
|
||
|
material=${nodes.Cube.material}
|
||
|
onClick=${e=>
|
||
|
{
|
||
|
e.stopPropagation();
|
||
|
props.handler();
|
||
|
}
|
||
|
} />
|
||
|
</group>`
|
||
|
}
|
||
|
|
||
|
useGLTF.preload("/bounce.gltf");
|
||
|
|
||
|
function App()
|
||
|
{
|
||
|
const [State, Dispatch] = Game.Consume();
|
||
|
/** @type Array<JSX.Element> */
|
||
|
const children = State.Goals.map((g, i)=>
|
||
|
{
|
||
|
return React.createElement(Model,
|
||
|
{
|
||
|
goal:g,
|
||
|
handler:()=>Dispatch({Type:"click", Payload:i}),
|
||
|
key:i
|
||
|
});
|
||
|
});
|
||
|
return html`
|
||
|
<div>
|
||
|
<div>
|
||
|
<dl>
|
||
|
<dt>Round</dt>
|
||
|
<dd>${State.Round}</dd>
|
||
|
<dt>Done</dt>
|
||
|
<dd>${ State.Done ? "Done!" : "Not Done :("}</dd>
|
||
|
<dd><button disabled=${!State.Done} onClick=${()=>Dispatch({Type:"round"})}>Next</button></dd>
|
||
|
</dl>
|
||
|
</div>
|
||
|
<${RTF.Canvas}>
|
||
|
<pointLight position=${[10, 10, 10]} />
|
||
|
<${PerspectiveCamera} makeDefault fov=${100} position=${[0, 0, 10]} />
|
||
|
<${React.Suspense}>
|
||
|
${children}
|
||
|
<//>
|
||
|
<//>
|
||
|
</div>`;
|
||
|
|
||
|
}
|
||
|
|
||
|
ReactDOM.render(
|
||
|
React.createElement(App),
|
||
|
document.getElementById('root')
|
||
|
)
|