three-fiber/app.js

158 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:true
},
Reducer(inState, inAction)
{
/**@type {(inGoals:Array<StateGoal>)=>boolean} */
const allDone =(inGoals)=>
{
for(let i=0; i<inGoals.length; i++)
{
if(!inGoals[i].Done)
{
return false;
}
}
return true;
}
/** @type State */ const clone = {...inState};
switch(inAction.Type)
{
case "click":
{
if(clone.Done){break;}
/**@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<clone.Round; 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}
onClick=${e=>
{
e.stopPropagation();
props.handler();
}
}>
<meshStandardMaterial color=${props.goal.Done ? "yellow" : "blue"}/>
</mesh>
</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')
)