workspaces folders

This commit is contained in:
azykov@mail.ru
2026-06-12 11:46:14 +03:00
parent 88b36988ff
commit 36f76efdd7
102 changed files with 0 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>blockly3d</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+119
View File
@@ -0,0 +1,119 @@
import { BrowserRouter, Link, Outlet, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom';
import { VoxelEditorPage } from './components/voxelEditor/VoxelEditorPage';
import { useEffect, useLayoutEffect, useRef } from 'react';
import { reaction } from 'mobx';
import { ThreeView } from './components/ThreeView';
import { state } from './state';
import './App.scss';
import { Panels } from './components/Panels';
function StateToUrlSync() {
const navigate = useNavigate();
const { pathname } = useLocation();
const pathnameRef = useRef(pathname);
useLayoutEffect(() => { pathnameRef.current = pathname; });
useEffect(() => reaction(
() => ({
isGame: !!state.game,
selectedObjectId: state.worldEditor.selection?.type === 'object' && state.worldEditor.selection.id,
selectedObjectTypeId: state.worldEditor.selection?.type === 'objectType' && state.worldEditor.selection.id,
}),
({ isGame, selectedObjectId, selectedObjectTypeId }) => {
let target: string;
if (isGame)
target = '/game';
else if (selectedObjectId)
target = `/editor/object/${selectedObjectId}`;
else if (selectedObjectTypeId)
target = `/editor/objectType/${selectedObjectTypeId}`;
else
target = '/editor';
const current = pathnameRef.current;
if (current === target || current.startsWith(target + '/'))
return;
navigate(target);
},
), [navigate]);
return null;
}
function EditorRoute() {
useEffect(() => {
if (state.game)
state.stopGame();
state.worldEditor.resetSelection();
}, []);
return null;
}
function EditorObjectRoute() {
const { id } = useParams<{ id: string }>();
useEffect(() => {
if (!id)
return;
if (state.game)
state.stopGame();
const editMode = state.worldEditor.selection?.type === 'object' && state.worldEditor.selection.id === id
? state.worldEditor.selection.editMode ?? 'translate'
: 'translate';
state.worldEditor.setSelectedObject({ id, editMode });
}, [id]);
return null;
}
function EditorObjectTypeRoute() {
const { id } = useParams<{ id: string }>();
useEffect(() => {
if (!id)
return;
if (state.game)
state.stopGame();
const editMode = state.worldEditor.selection?.type === 'objectType' && state.worldEditor.selection.id === id
? state.worldEditor.selection.editMode ?? 'scripts'
: 'scripts';
state.worldEditor.setSelectedObjectType({ id, editMode });
}, [id]);
return null;
}
function GameRoute() {
useEffect(() => {
if (!state.game)
state.startGame();
}, []);
return null;
}
function HomePage() {
return <Link to="/editor">Open Editor</Link>;
}
function EditorLayout() {
return (
<>
<Outlet />
<ThreeView />
<Panels />
</>
);
}
export const App = function () {
return (
<BrowserRouter>
<StateToUrlSync />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/editor/objectType/:id/voxels" element={<VoxelEditorPage />} />
<Route element={<EditorLayout />}>
<Route path="/editor" element={<EditorRoute />} />
<Route path="/editor/object/:id" element={<EditorObjectRoute />} />
<Route path="/editor/objectType/:id" element={<EditorObjectTypeRoute />} />
<Route path="/game" element={<GameRoute />} />
</Route>
</Routes>
</BrowserRouter>
);
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,24 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['console_log_action'] = {
init(this: Blockly.Block) {
this.appendValueInput('VALUE')
.setAlign(Blockly.inputs.Align.RIGHT)
// .setCheck('String')
.appendField('print');
this.setInputsInline(false);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setTooltip('Print value to console');
this.setHelpUrl('');
this.setColour(150);
}
};
javascriptGenerator.forBlock['console_log_action'] = function (block, generator) {
const valueValue = generator.valueToCode(block, 'VALUE', Order.ATOMIC);
return `console.log(${valueValue});\n`;
};
export { };
+3
View File
@@ -0,0 +1,3 @@
export * from './consoleLog';
export * from './physics';
export * from './waitSeconds';
@@ -0,0 +1,29 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['physics_apply_impulse_action'] = {
init(this: Blockly.Block) {
this.appendDummyInput('')
.appendField('Push me in direction of');
this.appendValueInput('POSITION')
.setCheck('DPos');
this.appendDummyInput('')
.appendField('with force')
.appendField(new Blockly.FieldNumber(1, -10, 10, 0.01), 'FORCE');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setTooltip('Push me in a direction');
this.setColour(150);
}
};
javascriptGenerator.forBlock['physics_apply_impulse_action'] = function (block, generator) {
const positionValue = generator.valueToCode(block, 'POSITION', Order.ATOMIC);
const forceValue = block.getFieldValue('FORCE');
return `api.applyImpulse(${positionValue}, ${forceValue});\n`;
};
export { };
@@ -0,0 +1 @@
export * from './applyImpulse';
@@ -0,0 +1,22 @@
import * as Blockly from "blockly";
import { javascriptGenerator } from "blockly/javascript";
Blockly.Blocks['wait_seconds_action'] = {
init(this: Blockly.Block) {
this.appendDummyInput()
.appendField('Wait')
.appendField(new Blockly.FieldNumber(1, 0, 3600, 0.1), 'SECONDS')
.appendField('seconds');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setTooltip('Pause execution for N seconds');
this.setColour(120);
}
};
javascriptGenerator.forBlock['wait_seconds_action'] = function (block) {
const seconds = block.getFieldValue('SECONDS');
return `yield wait(${seconds});\n`;
};
export { };
+17
View File
@@ -0,0 +1,17 @@
import * as Blockly from "blockly";
import { javascriptGenerator } from "blockly/javascript";
Blockly.common.defineBlocksWithJsonArray([{
"type": "game_start_event",
"message0": "On game start",
"nextStatement": null,
"style": "event_hat",
"tooltip": "Runs when the game starts.",
"helpUrl": ""
}]);
javascriptGenerator.forBlock['game_start_event'] = function (_block, _generator) {
return '';
};
export { };
+18
View File
@@ -0,0 +1,18 @@
import * as Blockly from "blockly";
import { javascriptGenerator } from "blockly/javascript";
Blockly.Blocks['object_clicked_event'] = {
init(this: Blockly.Block) {
this.appendDummyInput()
.appendField('When I\'m clicked');
this.setStyle('event_hat');
this.setNextStatement(true);
this.setTooltip('Fires when an object is clicked');
}
};
javascriptGenerator.forBlock['object_clicked_event'] = function (_block, _generator) {
return '';
};
export { };
+4
View File
@@ -0,0 +1,4 @@
export * from './gameStart';
export * from './objectTouchedMe';
export * from './playerTouchedMe';
export * from './imClicked';
@@ -0,0 +1,22 @@
import * as Blockly from "blockly";
import { javascriptGenerator } from "blockly/javascript";
Blockly.Blocks['object_touch_start_event'] = {
init(this: Blockly.Block) {
this.appendDummyInput('NAME')
.appendField('When');
this.appendValueInput('NAME')
.setCheck('Object');
this.appendDummyInput('NAME')
.appendField('touches me');
this.setStyle('event_hat');
this.setNextStatement(true);
this.setTooltip('Fires when an object touches me');
}
};
javascriptGenerator.forBlock['object_touch_start_event'] = function (_block, _generator) {
return '';
};
export { };
@@ -0,0 +1,18 @@
import * as Blockly from "blockly";
import { javascriptGenerator } from "blockly/javascript";
Blockly.Blocks['player_touch_start_event'] = {
init(this: Blockly.Block) {
this.appendDummyInput()
.appendField('When player touches me');
this.setStyle('event_hat');
this.setNextStatement(true);
this.setTooltip('Fires when player touches me');
}
};
javascriptGenerator.forBlock['player_touch_start_event'] = function (_block, _generator) {
return '';
};
export { };
@@ -0,0 +1 @@
export * from './objectType';
@@ -0,0 +1,12 @@
import * as Blockly from "blockly";
export class ObjecTypeField extends Blockly.Field {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(value: any, validator: any) {
super(value, validator);
this.SERIALIZABLE = true;
}
}
Blockly.fieldRegistry.register('field_object_type', ObjecTypeField);
+3
View File
@@ -0,0 +1,3 @@
export function startGame() {
}
+35
View File
@@ -0,0 +1,35 @@
import * as Blockly from "blockly";
import { javascriptGenerator } from "blockly/javascript";
export function generateCode(workspace: Blockly.WorkspaceSvg): string {
let result = '';
for (const block of workspace.getTopBlocks(true)) {
if (block.type.endsWith('_event')) {
let body = '';
let next = block.getNextBlock();
while (next) {
// opt_thisOnly=true prevents scrub_ from also chaining — we do it manually
body += javascriptGenerator.blockToCode(next, true);
next = next.getNextBlock();
}
body = javascriptGenerator.prefixLines(body, javascriptGenerator.INDENT.repeat(2));
// const args = Array.from(block.getFields())
// .filter(field => field.name !== undefined)
// .map(field => `${field.name}: ${JSON.stringify(field.getValue())}`)
// .join(', ');
//TODO use args
result += `onGameEvent(
'${block.type.replace(/_event$/, '')}',
function*(context) {
if (context.object && (context.object.id !== object.id))
return;
${body} },
);
`;
}
}
return result;
}
+6
View File
@@ -0,0 +1,6 @@
export * from './events';
export * from './values';
export * from './actions';
export * from './theme';
export * from './generateCode';
export * from './fieldTypes';
+17
View File
@@ -0,0 +1,17 @@
import * as Blockly from "blockly";
export const gameTheme = Blockly.Theme.defineTheme(
'gameTheme',
{
name: 'GameTheme',
base: Blockly.Themes.Classic,
blockStyles: {
'event_hat': {
colourPrimary: '#FFAB19',
colourSecondary: '#CF8B17',
colourTertiary: '#CF8B17',
hat: 'cap',
},
},
},
);
@@ -0,0 +1,21 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['any_object_of_type_value'] = {
init(this: Blockly.Block) {
this.appendDummyInput('NAME')
.appendField('Any object of type');
this.appendValueInput('NAME')
.setCheck('ObjectType');
this.setInputsInline(true);
this.setOutput(true, 'Object');
this.setTooltip('Any object of type');
this.setColour(315);
}
};
javascriptGenerator.forBlock['any_object_of_type_value'] = function (_block, _generator) {
return ['123', Order.NONE];
};
export { };
@@ -0,0 +1,19 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['current_object_value'] = {
init(this: Blockly.Block) {
this.appendEndRowInput('NAME')
.appendField('Me');
this.setInputsInline(false);
this.setOutput(true, 'Object');
this.setTooltip('Returns current object instance');
this.setColour(315);
}
};
javascriptGenerator.forBlock['current_object_value'] = function (_block, _generator) {
return ['object', Order.NONE];
};
export { };
@@ -0,0 +1,19 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['current_object_type_value'] = {
init(this: Blockly.Block) {
this.appendEndRowInput('NAME')
.appendField('My type');
this.setInputsInline(false);
this.setOutput(true, 'ObjectType');
this.setTooltip('Returns current object type');
this.setColour(315);
}
};
javascriptGenerator.forBlock['current_object_type_value'] = function (_block, _generator) {
return ['objectType', Order.NONE];
};
export { };
+29
View File
@@ -0,0 +1,29 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['dpos_value'] = {
init(this: Blockly.Block) {
this.appendDummyInput()
.appendField('Direction [')
.appendField(new Blockly.FieldNumber(0, -Infinity, Infinity, 0.01), 'X')
.appendField(',')
.appendField(new Blockly.FieldNumber(0, -Infinity, Infinity, 0.01), 'Y')
.appendField(',')
.appendField(new Blockly.FieldNumber(0, -Infinity, Infinity, 0.01), 'Z');
this.appendDummyInput()
.appendField(']');
this.setInputsInline(true);
this.setOutput(true, 'DPos');
this.setTooltip('Direction [dx,dy,dz]');
this.setColour(315);
}
};
javascriptGenerator.forBlock['dpos_value'] = function (block, _generator) {
const xValue = block.getFieldValue('X');
const yValue = block.getFieldValue('Y');
const zValue = block.getFieldValue('Z');
return [`[${xValue},${yValue},${zValue}]`, Order.ATOMIC];
};
export { };
@@ -0,0 +1,22 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['direction_to_position_value'] = {
init(this: Blockly.Block) {
this.appendDummyInput()
.appendField('Direction to');
this.appendValueInput('TARGET')
.setCheck('Pos');
this.setInputsInline(true);
this.setOutput(true, 'DPos');
this.setTooltip('Direction to a position');
this.setColour(315);
}
};
javascriptGenerator.forBlock['direction_to_position_value'] = function (block, generator) {
const targetValue = generator.valueToCode(block, 'TARGET', Order.ATOMIC);
return [`((a,b)=>[a[0]-b[0],a[1]-b[1],a[2]-b[2]])(${targetValue},object.position)`, Order.NONE];
};
export { };
+10
View File
@@ -0,0 +1,10 @@
export * from './currentObject';
export * from './currentObjectType';
export * from './anyObjectOfType';
export * from './objectType';
export * from './positionOfObject';
export * from './position';
export * from './direction';
export * from './objectById';
export * from './directionToPosition';
export * from './playerObject';
@@ -0,0 +1,21 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['object_by_id_value'] = {
init(this: Blockly.Block) {
this.appendEndRowInput()
.appendField('Object with id')
.appendField(new Blockly.FieldTextInput(''), 'TARGET_ID');
this.setInputsInline(false);
this.setOutput(true, 'Object');
this.setTooltip('Returns object by id, if any');
this.setColour(315);
}
};
javascriptGenerator.forBlock['object_by_id_value'] = function (block, _generator) {
const targetIdValue = block.getFieldValue('TARGET_ID');
return [`context.scene.objects['${targetIdValue}']`, Order.ATOMIC];
};
export { };
@@ -0,0 +1,33 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
import { toJS } from "mobx";
import { state } from "../../state";
export function getObjectTypeDropDownOptions(): [string, string][] {
const types = Object.values(toJS(state.world.data.objectTypes));
const values: [string, string][] = [
['Any object', '*'],
...types.map(t => [t.name, t.id] as [string, string]),
];
return values;
}
Blockly.Blocks['object_type_value'] = {
init(this: Blockly.Block) {
this.appendEndRowInput('NAME')
.appendField(
new Blockly.FieldDropdown(getObjectTypeDropDownOptions),
'OBJECT_TYPE',
);
this.setInputsInline(false);
this.setOutput(true, 'ObjectType');
this.setTooltip('Object type');
this.setColour(315);
}
};
javascriptGenerator.forBlock['current_object_type_value'] = function (_block, _generator) {
return ['objectType', Order.NONE];
};
export { };
@@ -0,0 +1,19 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['player_object_value'] = {
init(this: Blockly.Block) {
this.appendEndRowInput()
.appendField('Player');
this.setInputsInline(false);
this.setOutput(true, 'Object');
this.setTooltip('Returns object that is controlled by player');
this.setColour(315);
}
};
javascriptGenerator.forBlock['player_object_value'] = function (_block, _generator) {
return ['(scene.playerObjectId?scene.objects[scene.playerObjectId]:undefined)', Order.ATOMIC];
};
export { };
+29
View File
@@ -0,0 +1,29 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['pos_value'] = {
init(this: Blockly.Block) {
this.appendDummyInput('POSITION')
.appendField('Position [')
.appendField(new Blockly.FieldNumber(0, -Infinity, Infinity, 0.01), 'X')
.appendField(',')
.appendField(new Blockly.FieldNumber(0, -Infinity, Infinity, 0.01), 'Y')
.appendField(',')
.appendField(new Blockly.FieldNumber(0, -Infinity, Infinity, 0.01), 'Z');
this.appendDummyInput()
.appendField(']');
this.setInputsInline(true);
this.setOutput(true, 'Pos');
this.setTooltip('Position [x,y,z]');
this.setColour(315);
}
};
javascriptGenerator.forBlock['pos_value'] = function (block, _generator) {
const xValue = block.getFieldValue('X');
const yValue = block.getFieldValue('Y');
const zValue = block.getFieldValue('Z');
return [`[${xValue},${yValue},${zValue}]`, Order.ATOMIC];
};
export { };
@@ -0,0 +1,22 @@
import * as Blockly from "blockly";
import { javascriptGenerator, Order } from "blockly/javascript";
Blockly.Blocks['position_of_object_value'] = {
init(this: Blockly.Block) {
this.appendDummyInput()
.appendField('Position of');
this.appendValueInput('TARGET')
.setCheck('Object');
this.setInputsInline(true);
this.setOutput(true, 'Pos');
this.setTooltip('Position of an object');
this.setColour(315);
}
};
javascriptGenerator.forBlock['position_of_object_value'] = function (block, generator) {
const targetValue = generator.valueToCode(block, 'TARGET', Order.ATOMIC);
return [`${targetValue}.position`, Order.NONE];
};
export { };
+164
View File
@@ -0,0 +1,164 @@
import { observer } from "mobx-react-lite";
import type { ObjectType, RuntimeGameObjectInstance } from "../types";
import { ObjectViewInternal, type ObjectViewInternalHandle } from "./ObjectViewInternal";
import { useEffect, useMemo, useRef } from "react";
import { Euler, Quaternion, Vector3 } from "three";
import { useFrame, useThree } from "@react-three/fiber";
import { useKeyboardControls } from "@react-three/drei";
import { useRapier, useBeforePhysicsStep } from "@react-three/rapier";
import { joystickValues } from "../joystickInput";
import { state } from "../state";
const SPEED = 5;
const JUMP_SPEED = 8;
const GRAVITY = 20;
const SENSITIVITY = 0.002;
const LOOK_RATE = 2000;
type RapierWorldCreateCharacterControllerFunction = ReturnType<typeof useRapier>['world']['createCharacterController'];
type KinematicCharacterController = ReturnType<RapierWorldCreateCharacterControllerFunction>;
const _q = new Quaternion();
const _e = new Euler(0, 0, 0, 'YXZ');
const _offset = new Vector3();
const _charPos = new Vector3();
const _lookAt = new Vector3();
type PlayerObjectViewProps = {
object: RuntimeGameObjectInstance;
objectType: ObjectType;
}
export const PlayerObjectView = observer(function ({ object, objectType }: PlayerObjectViewProps) {
const handleRef = useRef<ObjectViewInternalHandle>(null);
const [, get] = useKeyboardControls();
const { gl } = useThree();
const { world } = useRapier();
const { shoulderOffset, lookAtY } = useMemo(() => {
const bb = object.cache.boundingBox;
const W = bb.max[0] - bb.min[0];
const H = bb.max[1] - bb.min[1];
const D = bb.max[2] - bb.min[2];
const radius = Math.sqrt(W * W + H * H + D * D);
const centerY = (bb.min[1] + bb.max[1]) / 2;
return {
shoulderOffset: new Vector3(W * 0.1, centerY + H * 0.3, bb.max[2] + radius),
lookAtY: centerY,
};
}, [object.cache.boundingBox]);
const yawRef = useRef(0);
const pitchRef = useRef(0);
const mouseRef = useRef({ x: 0, y: 0 });
const jumpPressedRef = useRef(false);
const vyRef = useRef(0);
const controllerRef = useRef<KinematicCharacterController | null>(null);
useEffect(() => {
const controller = world.createCharacterController(0.01);
controller.setUp({ x: 0, y: 1, z: 0 });
controller.setMaxSlopeClimbAngle(45 * Math.PI / 180);
controller.setMinSlopeSlideAngle(30 * Math.PI / 180);
controller.enableAutostep(0.5, 0.2, true);
controller.enableSnapToGround(0.5);
controller.setApplyImpulsesToDynamicBodies(true);
controllerRef.current = controller;
return () => { world.removeCharacterController(controller); };
}, [world]);
useEffect(() => {
const canvas = gl.domElement;
const onClick = () => { if (document.pointerLockElement !== canvas)
canvas.requestPointerLock()?.catch(() => {}); };
const onMouseMove = (e: MouseEvent) => {
if (document.pointerLockElement !== canvas)
return;
mouseRef.current.x += e.movementX;
mouseRef.current.y += e.movementY;
};
canvas.addEventListener('click', onClick);
document.addEventListener('mousemove', onMouseMove);
return () => {
canvas.removeEventListener('click', onClick);
document.removeEventListener('mousemove', onMouseMove);
};
}, [gl]);
useBeforePhysicsStep((world) => {
const rb = handleRef.current?.rb;
if (!rb)
return;
const collider = rb.collider(0);
if (!collider)
return;
const controller = controllerRef.current;
if (!controller)
return;
if (state.game?.isPaused)
return;
const dt = world.timestep;
const yaw = yawRef.current;
const { forward, backward, left, right, jump } = get();
const fwdX = -Math.sin(yaw);
const fwdZ = -Math.cos(yaw);
const fwdScale = Math.max(-1, Math.min(1, (forward ? 1 : 0) - (backward ? 1 : 0) + joystickValues.move.y));
const rightScale = Math.max(-1, Math.min(1, (right ? 1 : 0) - (left ? 1 : 0) + joystickValues.move.x));
const vx = (fwdX * fwdScale - fwdZ * rightScale) * SPEED;
const vz = (fwdZ * fwdScale + fwdX * rightScale) * SPEED;
const jumpInput = jump || joystickValues.jump;
const isGrounded = controller.computedGrounded();
if (jumpInput && !jumpPressedRef.current && isGrounded) {
vyRef.current = JUMP_SPEED;
} else if (!isGrounded) {
vyRef.current -= GRAVITY * dt;
} else {
vyRef.current = 0;
}
jumpPressedRef.current = jumpInput;
controller.computeColliderMovement(collider, { x: vx * dt, y: vyRef.current * dt, z: vz * dt });
const corrected = controller.computedMovement();
const t = rb.translation();
rb.setNextKinematicTranslation({
x: t.x + corrected.x,
y: t.y + corrected.y,
z: t.z + corrected.z,
});
_q.setFromEuler(_e.set(0, yaw, 0));
rb.setNextKinematicRotation({ x: _q.x, y: _q.y, z: _q.z, w: _q.w });
});
useFrame(({ camera }, delta) => {
const rb = handleRef.current?.rb;
if (!rb)
return;
mouseRef.current.x += joystickValues.look.x * LOOK_RATE * delta;
mouseRef.current.y += -joystickValues.look.y * LOOK_RATE * delta;
yawRef.current -= mouseRef.current.x * SENSITIVITY;
pitchRef.current = Math.max(-0.85, Math.min(0.5, pitchRef.current - mouseRef.current.y * SENSITIVITY));
mouseRef.current.x = 0;
mouseRef.current.y = 0;
const t = rb.translation();
_charPos.set(t.x, t.y, t.z);
_offset.copy(shoulderOffset).applyEuler(_e.set(pitchRef.current, yawRef.current, 0, 'YXZ'));
camera.position.copy(_charPos).add(_offset);
_lookAt.set(t.x, t.y + lookAtY, t.z);
camera.lookAt(_lookAt);
});
return (
<ObjectViewInternal
ref={handleRef}
object={object}
objectType={objectType}
isPlayer
/>
);
});
@@ -0,0 +1,29 @@
import { observer } from "mobx-react-lite";
import type { RuntimeGameObjectInstance } from "../types";
import { state } from "../state";
import { ObjectViewInternal } from "./ObjectViewInternal";
import { PlayerObjectView } from "./CharacterView";
export type GameObjectViewProps = {
object: RuntimeGameObjectInstance;
isPlayer: boolean;
}
export const GameObjectView = observer(function (props: GameObjectViewProps) {
const objectType = state.world.getObjectTypeById(props.object.typeId);
if (!objectType)
return null;
if (props.isPlayer)
return <PlayerObjectView object={props.object as RuntimeGameObjectInstance} objectType={objectType} />;
function handleClick(): void {
state.game?.emitEvent('object_clicked', { object: props.object });
}
return <ObjectViewInternal
{...props}
objectType={objectType}
onClick={handleClick}
/>;
});
+25
View File
@@ -0,0 +1,25 @@
import { observer } from "mobx-react-lite";
import { SceneView } from "./SceneView";
import { state } from "../state";
import { useFrame } from "@react-three/fiber";
import { Suspense } from "react";
import { Physics } from "@react-three/rapier";
export const GameView = observer(function () {
const game = state.game;
useFrame((_, delta) => {
state.game?.tick(delta);
});
if (!game)
return null;
return (<>
<Suspense>
<Physics paused={game.isPaused} debug>
<SceneView scene={game.scene} />
</Physics>
</Suspense>
</>);
});
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useRef } from "react";
import { create } from "nipplejs";
import { joystickValues } from "../joystickInput";
const isTouch = navigator.maxTouchPoints > 0;
// const isTouch = true; // debug
export function JoystickView() {
const moveZoneRef = useRef<HTMLDivElement>(null);
const jumpBtnRef = useRef<HTMLDivElement>(null);
const lookZoneRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isTouch)
return;
const moveZone = moveZoneRef.current;
const jumpBtn = jumpBtnRef.current;
const lookZone = lookZoneRef.current;
if (!moveZone || !jumpBtn || !lookZone)
return;
let moveJoystick: ReturnType<typeof create>;
let lookJoystick: ReturnType<typeof create>;
const setup = () => {
moveJoystick = create({
zone: moveZone,
mode: 'static',
position: { left: '80px', bottom: '80px' },
color: 'white',
shape: 'circle',
size: 100,
});
lookJoystick = create({
zone: lookZone,
mode: 'dynamic',
dataOnly: true,
});
moveJoystick.on('move', (evt) => {
joystickValues.move.x = evt.data.vector.x;
joystickValues.move.y = evt.data.vector.y;
});
moveJoystick.on('end', () => {
joystickValues.move = { x: 0, y: 0 };
});
lookJoystick.on('move', (evt) => {
joystickValues.look = evt.data.vector;
});
lookJoystick.on('end', () => {
joystickValues.look = { x: 0, y: 0 };
});
};
const teardown = () => {
moveJoystick?.destroy();
lookJoystick?.destroy();
joystickValues.move = { x: 0, y: 0 };
joystickValues.look = { x: 0, y: 0 };
joystickValues.jump = false;
};
setup();
const onJumpDown = () => { joystickValues.jump = true; };
const onJumpUp = () => { joystickValues.jump = false; };
jumpBtn.addEventListener('pointerdown', onJumpDown);
jumpBtn.addEventListener('pointerup', onJumpUp);
jumpBtn.addEventListener('pointercancel', onJumpUp);
jumpBtn.addEventListener('pointerleave', onJumpUp);
// When the OS interrupts a touch (app switch, notification, etc.) the browser
// fires pointercancel instead of pointerup, leaving nipplejs with a stuck active
// identifier. In dynamic mode this silently drops all subsequent touches on the
// look zone. Destroying and recreating resets nipplejs's internal touch tracking.
const onVisibilityChange = () => {
if (document.hidden) {
teardown();
} else {
setup();
}
};
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
teardown();
jumpBtn.removeEventListener('pointerdown', onJumpDown);
jumpBtn.removeEventListener('pointerup', onJumpUp);
jumpBtn.removeEventListener('pointercancel', onJumpUp);
jumpBtn.removeEventListener('pointerleave', onJumpUp);
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, []);
if (!isTouch)
return null;
return (
<>
<div
ref={moveZoneRef}
id="move-zone"
style={{ position: 'absolute', left: 0, bottom: 0, width: '30vw', height: '30vw', touchAction: 'none' }}
/>
<div
ref={lookZoneRef}
id="look-zone"
style={{ position: 'absolute', right: 0, bottom: 0, width: '100%', height: '100%', touchAction: 'none' }}
/>
<div
ref={jumpBtnRef}
id="jump-zone"
style={{
position: 'absolute',
right: 30,
bottom: 30,
width: 80,
height: 80,
borderRadius: '50%',
background: 'rgba(255,255,255,0.25)',
border: '2px solid rgba(255,255,255,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: 14,
fontWeight: 'bold',
touchAction: 'none',
userSelect: 'none',
pointerEvents: 'auto',
}}
>
JUMP
</div>
</>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { observer } from "mobx-react-lite";
import { RenderInfoView } from "./RenderInfoView";
import { MenuView } from "./MenuView";
import { state } from "../state";
import { Panel } from "./Panel";
export const LeftPanel = observer(function () {
const isGame = state.game && !state.game.isPaused;
if (isGame)
return null;
// function handleLoadWorld(): void {
// state.world.load();
// }
function handleLoadMockWorld(): void {
state.world.loadMock();
}
return <Panel side='left'>
<MenuView />
<div className="gap" />
{/* <button onClick={handleLoadWorld}>Load</button> */}
<button onClick={handleLoadMockWorld}>Load mock world</button>
<div className="debug"><RenderInfoView /></div>
</Panel>;
});
+30
View File
@@ -0,0 +1,30 @@
import { observer } from "mobx-react-lite";
import { state } from "../state";
import { Panel } from "./Panel";
import { ScriptEditorView } from "./scriptEditor/ScriptEditorView";
export const MainPanel = observer(function () {
const isGame = state.game && !state.game.isPaused;
if (isGame)
return null;
const selectedObjectTypeId = state.worldEditor.selection?.type === 'objectType'
? state.worldEditor.selection.id
: undefined;
if (!selectedObjectTypeId)
return null;
const objectType = state.world.getObjectTypeById(selectedObjectTypeId);
if (!objectType)
return null;
const objects = Object.values(state.worldEditor.scene.objects)
.filter((o) => o.typeId === objectType.id);
return <Panel side='main'>
<header>
<div><span className="title">{objectType?.name}</span> ({objects.length} object{objects.length > 1 ? 's' : ''})</div>
</header>
<ScriptEditorView objectType={objectType} />
</Panel>;
});
+61
View File
@@ -0,0 +1,61 @@
.menu {
user-select: none;
font-size: 16px;
& details {
&>summary {
list-style: none;
&::-webkit-details-marker {
display: none;
}
display: flex;
cursor: pointer;
border-radius: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: rgba(255, 255, 255, 1);
&:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
&.selected {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
&>.title {
padding: 8px;
flex: 1;
overflow-x: hidden;
text-overflow: ellipsis;
}
&>.actions {
display: flex;
&>* {
padding: 8px;
}
&>*:hover {
background: rgba(255, 255, 255, 0.08);
}
}
& svg {
vertical-align: middle;
box-sizing: border-box;
}
}
& details {
padding-left: 12px;
}
}
}
+61
View File
@@ -0,0 +1,61 @@
import { observer } from 'mobx-react-lite';
import { useEffect, useRef } from 'react';
import { state } from '../state';
import './MenuView.scss';
import type { MenuNode } from '../state/menuState';
import { useNavigate } from 'react-router-dom';
export const MenuNodeView = observer(function ({ node }: { node: MenuNode }) {
const forceOpen = state.menu.nodeContainsSelected(node);
const navigate = useNavigate();
const ref = useRef<HTMLDetailsElement>(null);
useEffect(() => {
if (forceOpen && ref.current)
ref.current.open = true;
}, [forceOpen]);
const classNames = [];
if (node.selected?.())
classNames.push('selected');
if (node.className !== undefined)
classNames.push(node.className);
return (
<details ref={ref}>
<summary
className={classNames.join(' ')}
onClick={() => node.onClick?.()}
>
<div className="title">{node.title}</div>
{node.actions && <div className="actions">
{node.actions.map((action) =>
<div
key={action.id}
title={action.tooltip}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
action.onClick?.();
if (action.link)
navigate(action.link);
}}
>
{action.content}
</div>
)}
</div>}
</summary>
{node.children?.map((child) => <MenuNodeView key={child.id} node={child} />)}
</details>
);
});
export const MenuView = observer(function () {
return (
<nav className="menu">
{state.menu.nodes.map((node) => <MenuNodeView key={node.id} node={node} />)}
</nav>
);
});
@@ -0,0 +1,101 @@
import { observer } from "mobx-react-lite";
import type { ObjectInstance, R3, Runtime } from "../types";
import { useMemo, useRef, type RefObject } from "react";
import type { Group } from "three";
import { TransformControls, useHelper } from "@react-three/drei";
import { BoxHelper } from "three";
import { state } from "../state";
import { nextObjectEditMode } from "../state/worldEditorState";
import { ObjectViewInternal, type ObjectViewInternalHandle } from "./ObjectViewInternal";
type ObjectEditorViewProps = {
object: Runtime<ObjectInstance>;
isPlayer: boolean;
}
type SelectionOverlayProps = {
objectId: string;
ref: RefObject<ObjectViewInternalHandle | null>;
onTransformEnd: () => void;
}
// Separate observer so only the 2 affected instances (selected/deselected)
// re-render on selection change, not all N objects in the scene.
const SelectionOverlay = observer(function ({ objectId, ref, onTransformEnd }: SelectionOverlayProps) {
const isSelected = state.worldEditor.isEnabled &&
state.worldEditor.selection?.type === 'object' &&
state.worldEditor.selection.id === objectId;
const selectionMode = isSelected &&
state.worldEditor.selection?.type === 'object'
? state.worldEditor.selection?.editMode
: undefined;
// Stable virtual ref that reads through to the group inside the handle
const groupRef = useMemo<RefObject<Group | null>>(
() => ({ get current() { return ref.current?.group ?? null; } }),
[ref],
);
useHelper(isSelected ? groupRef : { current: null }, BoxHelper, 'white');
if (!isSelected || selectionMode === undefined || !groupRef.current)
return null;
return (
<TransformControls
object={groupRef as RefObject<Group>}
mode={selectionMode}
onMouseUp={onTransformEnd}
/>
);
});
export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectEditorViewProps) {
const dataRef = useRef<ObjectViewInternalHandle>(null);
// Only observes world object types — not selection state — so won't
// re-render when a different object is selected.
const objectType = state.world.getObjectTypeById(object.typeId);
if (!objectType)
return null;
// Track cache so observer re-renders when refreshObjectTypeCaches is called
void object.cache.updatedAt;
function handleClick() {
if (state.worldEditor.isEnabled) {
// Reading selection state inside an event handler: not tracked by observer.
const currentMode = state.worldEditor.selection?.type === 'object' && state.worldEditor.selection?.id === object.id
? state.worldEditor.selection?.editMode
: undefined;
state.worldEditor.setSelectedObject({ id: object.id, editMode: nextObjectEditMode(currentMode) });
}
}
function handleTransformEnd() {
const group = dataRef.current?.group;
if (group)
state.worldEditor.setObjectTransform(
object.id,
group.position.toArray(),
group.rotation.toArray().slice(0, 3) as R3,
group.scale.toArray(),
);
}
return (<>
<SelectionOverlay
objectId={object.id}
ref={dataRef}
onTransformEnd={handleTransformEnd}
/>
<ObjectViewInternal
ref={dataRef}
object={object}
isEditor
isPlayer={isPlayer}
objectType={objectType}
onClick={handleClick}
/>
</>);
});
@@ -0,0 +1,131 @@
import type { ObjectType, RuntimeGameObjectInstance, RuntimeObjectInstance } from "../types";
import { useEffect, useImperativeHandle, useRef, type Ref } from "react";
import { Euler, Quaternion, type Group } from "three";
import { Instance, Instances } from "@react-three/drei";
import type { ThreeEvent } from "@react-three/fiber";
import { ConvexHullCollider, RapierRigidBody } from "@react-three/rapier";
import { SyncRigidBody } from "./SyncRigidBody";
import { reaction, runInAction } from "mobx";
import { v3toRapier } from "../utils";
export type ObjectViewInternalHandle = {
group: Group | null;
rb: RapierRigidBody | null;
}
type ObjectViewInternalProps = {
object: Omit<RuntimeObjectInstance, 'typeId'>;
objectType: ObjectType;
isEditor?: boolean;
isPlayer?: boolean;
onClick?: () => void;
ref?: Ref<ObjectViewInternalHandle>;
}
// not observer, instead reacts only to object.version field
export const ObjectViewInternal = function ({ object, objectType, ref, ...props }: ObjectViewInternalProps) {
const rbRef = useRef<RapierRigidBody>(null);
const groupRef = useRef<Group | null>(null);
useImperativeHandle(ref, () => ({ group: groupRef.current, rb: rbRef.current }));
useEffect(
() => {
if (props.isPlayer)
return;
return reaction(
() => 'version' in object ? object.version : 0,
() => {
if (!rbRef.current)
return;
const gameObj = object as RuntimeGameObjectInstance;
rbRef.current.setTranslation(v3toRapier(gameObj.position), true);
const euler = new Euler(gameObj.rotation[0], gameObj.rotation[1], gameObj.rotation[2]);
const q = new Quaternion().setFromEuler(euler);
rbRef.current.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }, true);
groupRef.current?.scale.set(...gameObj.scale);
rbRef.current.setLinvel(v3toRapier(gameObj.linearVelocity), true);
rbRef.current.setAngvel(v3toRapier(gameObj.angularVelocity), true);
},
);
},
[object, object.id, props.isPlayer]
);
const gameObj = object as RuntimeGameObjectInstance;
useEffect(
() => {
return reaction(
() => gameObj.pendingActions.impulse,
(impulse) => {
if (!impulse || !rbRef.current)
return;
// eslint-disable-next-line prefer-const
let { direction, amplitude } = impulse;
amplitude *= 100;
const v = { x: direction[0] * amplitude, y: direction[1] * amplitude, z: direction[2] * amplitude };
rbRef.current.applyImpulse(v, true);
runInAction(() => {
gameObj.pendingActions.impulse = undefined;
});
},
);
},
[gameObj.pendingActions, object.id]
);
function handleClick(e: ThreeEvent<MouseEvent>) {
if (e.delta > 5)
return;
e.stopPropagation();
props.onClick?.();
}
return (
<SyncRigidBody
ref={rbRef}
colliders={false}
type={props.isPlayer ? 'kinematicPosition' : (object.physics ? 'dynamic' : 'fixed')}
gravityScale={object.gravityScale}
position={object.position}
rotation={object.rotation}
onSync={(data) => {
runInAction(() => {
object.position = data.position;
object.rotation = data.rotation;
if (!props.isEditor) {
(object as RuntimeGameObjectInstance).linearVelocity = data.linearVelocity;
(object as RuntimeGameObjectInstance).angularVelocity = data.radialVelocity;
}
// do not change object.version to not trigger rerender
});
}}
>
<group
ref={groupRef}
name={`${object.id} (${objectType.id} instance)`}
scale={object.scale}
onClick={handleClick}
>
{
object.cache.voxelGroups.map((vg) =>
<Instances key={`${vg.id}-${object.cache.updatedAt}`} limit={vg.positions.length}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={vg.color} opacity={vg.opacity} transparent={vg.opacity < 1} />
{vg.positions.map((pos, i) => <Instance key={i} position={pos} />)}
</Instances>
)
}
{object.cache.colliderMesh && <ConvexHullCollider args={[object.cache.colliderMesh[0]]} />}
</group>
</SyncRigidBody>
);
};
+13
View File
@@ -0,0 +1,13 @@
import { observer } from "mobx-react-lite";
import type { ReactNode } from "react";
export type PanelProps = {
side?: 'left' | 'right' | 'main';
children?: ReactNode;
}
export const Panel = observer(function ({ children, side = 'left' }: PanelProps) {
return <div className={`panel ${side}`}>
{children}
</div >;
});
+97
View File
@@ -0,0 +1,97 @@
.overlay-panels {
z-index: 15000;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
gap: 10px;
pointer-events: none;
&>.panel {
box-sizing: border-box;
margin: 10px;
display: flex;
flex-direction: column;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
padding: 4px;
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 0.25em;
&:not(:first-child) {
margin-left: none !important;
}
&.left,
&.right {
min-width: 30%;
flex: 0;
}
&.main {
flex: 1;
pointer-events: all;
}
&>header {
background: #00000040;
margin: -4px;
padding: 12px;
&>.title {
text-transform: capitalize;
}
}
&>*:not(.debug):not(.header):not(.gap) {
pointer-events: all;
}
&>.gap {
flex: 1;
}
&>.debug {
// border: 1px solid #ffffff20;
border-radius: 2px;
background: #00000040;
margin: -4px;
padding: 4px;
font-size: 75%;
display: flex;
gap: 0.5em;
align-items: start;
&>.chart {
display: inline;
& div {
display: inline;
position: relative !important;
z-index: unset !important;
opacity: 1 !important;
}
}
&>.metrics {
display: flex;
flex-wrap: wrap;
gap: 0.25em 0.5em;
&>* {
white-space: pre;
}
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
import { observer } from "mobx-react-lite";
import { LeftPanel } from "./LeftPanel";
import { MainPanel } from "./MainPanel";
import './Panels.scss';
export const Panels = observer(function () {
return <div className="overlay-panels">
<LeftPanel />
<MainPanel />
</div>;
});
@@ -0,0 +1,30 @@
import { useEffect, useRef } from "react";
import { observer } from "mobx-react-lite";
import { state } from "../state";
import { chartRef } from "./chartRef";
export const RenderInfoView = observer(function () {
const info = state.renderInfo;
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = containerRef.current;
if (!el)
return;
el.appendChild(chartRef.current);
return () => { el.removeChild(chartRef.current); };
}, []);
return <>
<div className="chart" ref={containerRef} />
{info != null && (
<div className="metrics">
<span>draw: {info.calls}</span>
<span>tri: {info.triangles}</span>
<span>shaders: {info.shaders}</span>
<span>geometries: {info.geometries}</span>
<span>textures: {info.textures}</span>
</div>
)}
</>;
});
@@ -0,0 +1,66 @@
import { Suspense, useRef } from 'react';
import type React from 'react';
import { Grid, OrbitControls } from '@react-three/drei';
import { observer } from 'mobx-react-lite';
import { state } from '../state';
import { SceneView } from './SceneView';
import { type OrthographicCamera, type PerspectiveCamera } from 'three';
import { CameraSync } from './tools/CameraSync';
import { Physics } from '@react-three/rapier';
export const SceneEditorView = observer(function () {
const controlsRef = useRef<React.ComponentRef<typeof OrbitControls>>(null);
const cameraAtStart = useRef<string>('');
function cameraSnapshot(camera: OrthographicCamera | PerspectiveCamera): string {
return JSON.stringify({
position: camera.position,
rotation: camera.rotation,
});
}
const handleStart = () => {
const controls = controlsRef.current;
if (!controls)
return;
cameraAtStart.current = cameraSnapshot(controls.object);
};
const handleEnd = () => {
const controls = controlsRef.current;
if (!controls)
return;
const snapshot = cameraSnapshot(controls.object);
if (snapshot === cameraAtStart.current)
return;
const [x, y, z] = controls.object.rotation.toArray();
state.worldEditor.setCamera({
position: controls.object.position.toArray(),
look: [x, y, z],
});
};
return (<>
<Suspense>
<OrbitControls
ref={controlsRef}
onStart={handleStart}
onEnd={handleEnd}
makeDefault
enableDamping={false}
/>
<CameraSync camera={state.worldEditor.camera} />
<Grid
sectionThickness={0.5}
cellThickness={0}
sectionColor="white"
infiniteGrid
/>
<Physics colliders={false} paused={true}>
<SceneView scene={state.worldEditor.scene} editMode />
</Physics>
</Suspense>
</>);
});
+23
View File
@@ -0,0 +1,23 @@
import { observer } from "mobx-react-lite";
import type { RuntimeScene } from "../types";
import { GameObjectView } from "./GameObjectView";
import { ObjectEditorView } from "./ObjectEditorView";
type SceneViewProps = {
scene: RuntimeScene,
editMode?: boolean;
}
export const SceneView = observer(function (props: SceneViewProps) {
return (<>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} intensity={1} />
{
Object.values(props.scene.objects).map((obj) => (
props.editMode
? <ObjectEditorView key={obj.id} object={obj} isPlayer={props.scene.playerObjectId === obj.id} />
: <GameObjectView key={obj.id} object={obj} isPlayer={props.scene.playerObjectId === obj.id} />
))
}
</>);
});
@@ -0,0 +1,78 @@
import { RigidBody, type RigidBodyProps, type RapierRigidBody } from "@react-three/rapier";
import { useFrame } from "@react-three/fiber";
import { useImperativeHandle, useRef, type Ref } from "react";
import { Euler, Quaternion } from "three";
import type { R3, V3 } from "../types";
export type SyncRigidBodyData = {
position: V3;
rotation: R3;
linearVelocity: V3;
radialVelocity: R3;
}
export type SyncRigidBodyOnSyncFunction = (data: SyncRigidBodyData) => void;
type SyncRigidBodyProps = RigidBodyProps & {
onSync: SyncRigidBodyOnSyncFunction;
ref?: Ref<RapierRigidBody | null>;
};
const _q = new Quaternion();
const _e = new Euler();
const EPS = 1e-6;
// indices: 0-2 position, 3-5 rotation, 6-8 linearVelocity, 9-11 radialVelocity
const PREV_INIT = new Float64Array(12).fill(Infinity);
function syncRigidBodyDataToArray(data: SyncRigidBodyData, arr: Float64Array): void {
arr[0] = data.position[0]; arr[1] = data.position[1]; arr[2] = data.position[2];
arr[3] = data.rotation[0]; arr[4] = data.rotation[1]; arr[5] = data.rotation[2];
arr[6] = data.linearVelocity[0]; arr[7] = data.linearVelocity[1]; arr[8] = data.linearVelocity[2];
arr[9] = data.radialVelocity[0]; arr[10] = data.radialVelocity[1]; arr[11] = data.radialVelocity[2];
}
function compareTwoFloatArrays(a: Float64Array, b: Float64Array, epsilon: number): boolean {
for (let i = 0; i < a.length; i++)
if (Math.abs(a[i] - b[i]) > epsilon)
return true;
return false;
}
export function SyncRigidBody({ onSync, ref, children, ...props }: SyncRigidBodyProps) {
const rbRef = useRef<RapierRigidBody>(null);
useImperativeHandle(ref, () => rbRef.current!);
const prevData = useRef<Float64Array>(PREV_INIT.slice());
const currentData = useRef<Float64Array>(PREV_INIT.slice());
useFrame(() => {
const body = rbRef.current;
if (!body)
return;
const { x, y, z } = body.translation();
const rot = body.rotation();
_q.set(rot.x, rot.y, rot.z, rot.w);
_e.setFromQuaternion(_q);
const lv = body.linvel();
const av = body.angvel();
const data: SyncRigidBodyData = {
position: [x, y, z],
rotation: [_e.x, _e.y, _e.z],
linearVelocity: [lv.x, lv.y, lv.z],
radialVelocity: [av.x, av.y, av.z],
};
syncRigidBodyDataToArray(data, currentData.current);
if (compareTwoFloatArrays(currentData.current, prevData.current, EPS)) {
prevData.current.set(currentData.current);
onSync(data);
}
});
return <RigidBody ref={rbRef} {...props}>{children}</RigidBody>;
}
+50
View File
@@ -0,0 +1,50 @@
import { Canvas } from '@react-three/fiber';
import { KeyboardControls, Stats } from '@react-three/drei';
import { chartRef } from './chartRef';
import { observer } from 'mobx-react-lite';
import { state } from '../state';
import { GameView } from './GameView';
import { SceneEditorView } from './SceneEditorView';
import { JoystickView } from './JoystickView';
import type { RefObject } from 'react';
import { IconPlayerPlayFilled, IconPlayerPauseFilled, IconPlayerStopFilled } from '@tabler/icons-react';
import { RenderInfoUpdater } from './renderInfo';
export const ThreeView = observer(function () {
const isGame = !!state.game;
return (
<KeyboardControls map={[
{ name: 'forward', keys: ['ArrowUp', 'w', 'W'] },
{ name: 'backward', keys: ['ArrowDown', 's', 'S'] },
{ name: 'left', keys: ['ArrowLeft', 'a', 'A'] },
{ name: 'right', keys: ['ArrowRight', 'd', 'D'] },
{ name: 'jump', keys: ['Space'] },
]}>
<div style={{ position: 'fixed', inset: 0, overflow: 'hidden' }}>
<Canvas
// camera={state.world.character.camera}
onPointerMissed={() => state.worldEditor.resetSelection()}
>
<Stats parent={chartRef as RefObject<HTMLElement>} />
<RenderInfoUpdater />
{isGame ? <GameView /> : <SceneEditorView />}
</Canvas>
{isGame && <JoystickView />}
<div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 4, fontSize: 14 }}>
{
state.game
? <>
<button onClick={() => state.stopGame()}><IconPlayerStopFilled size="1em" /></button>
{
state.game!.isPaused
? <button onClick={() => state.game!.resume()}><IconPlayerPlayFilled size="1em" /></button>
: <button onClick={() => state.game!.pause()}><IconPlayerPauseFilled size="1em" /></button>
}
</>
: <button onClick={() => state.startGame()}><IconPlayerPlayFilled size="1em" /></button>
}
</div>
</div>
</KeyboardControls>
);
});
+1
View File
@@ -0,0 +1 @@
export const chartRef = { current: document.createElement('span') };
+20
View File
@@ -0,0 +1,20 @@
import { useFrame, useThree } from "@react-three/fiber";
import { state } from "../state";
import { action } from "mobx";
export function RenderInfoUpdater() {
const { gl } = useThree();
useFrame(action(() => {
const { calls, triangles } = gl.info.render;
const shaders = gl.info.programs?.length ?? 0;
const { geometries, textures } = gl.info.memory;
state.setRenderInfo({
calls,
triangles,
shaders,
geometries,
textures,
});
}));
return null;
}
@@ -0,0 +1,11 @@
.script-editor {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
.blockly-container {
flex: 1;
min-height: 0;
}
}
@@ -0,0 +1,142 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import { observer } from "mobx-react-lite";
import { runInAction } from "mobx";
import * as Blockly from "blockly";
import type { ObjectType } from "../../types";
import './ScriptEditorView.scss';
import '../../blockly';
import { gameTheme, generateCode } from '../../blockly';
const TOOLBOX: Blockly.utils.toolbox.ToolboxDefinition = {
kind: 'categoryToolbox',
contents: [
{
kind: 'category',
name: 'Events',
colour: '180',
contents: [
{ kind: 'block', type: 'game_start_event' },
{ kind: 'block', type: 'object_touch_start_event' },
{ kind: 'block', type: 'player_touch_start_event' },
{ kind: 'block', type: 'object_clicked_event' },
],
},
{
kind: 'category',
name: 'Values',
colour: '260',
contents: [
{ kind: 'block', type: 'current_object_value' },
{ kind: 'block', type: 'current_object_type_value' },
{ kind: 'block', type: 'object_type_value' },
{ kind: 'block', type: 'any_object_of_type_value' },
{ kind: 'block', type: 'position_of_object_value' },
{ kind: 'block', type: 'pos_value' },
{ kind: 'block', type: 'dpos_value' },
{ kind: 'block', type: 'direction_to_position_value' },
{ kind: 'block', type: 'object_by_id_value' },
{ kind: 'block', type: 'player_object_value' },
],
},
{
kind: 'category',
name: 'Actions',
colour: '20',
contents: [
{ kind: 'block', type: 'console_log_action' },
{ kind: 'block', type: 'physics_apply_impulse_action' },
{ kind: 'block', type: 'wait_seconds_action' },
],
},
{
kind: 'category',
name: 'Text',
colour: '160',
contents: [
{ kind: 'block', type: 'text' },
],
},
],
};
type ScriptEditorViewProps = {
objectType: ObjectType;
}
export const ScriptEditorView = observer(function ({ objectType }: ScriptEditorViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const workspaceRef = useRef<Blockly.WorkspaceSvg | null>(null);
const objectTypeRef = useRef(objectType);
useLayoutEffect(() => { objectTypeRef.current = objectType; });
useEffect(() => {
if (!containerRef.current)
return;
const workspace = Blockly.inject(containerRef.current, { toolbox: TOOLBOX, theme: gameTheme });
workspaceRef.current = workspace;
const listener = (e: Blockly.Events.Abstract) => {
if (e.isUiEvent)
return;
runInAction(() => {
const current = objectTypeRef.current;
const serialized = JSON.stringify(Blockly.serialization.workspaces.save(workspace));
current.program = serialized;
try {
current.javascript = generateCode(workspace);
}
catch (err) {
console.log(err);
current.javascript = undefined;
}
console.log(current.javascript);
});
};
workspace.addChangeListener(listener);
if (objectType.program) {
try {
Blockly.Events.disable();
Blockly.serialization.workspaces.load(JSON.parse(objectType.program), workspace);
} catch {
// corrupt program, start fresh
} finally {
Blockly.Events.enable();
}
}
const resizeObserver = new ResizeObserver(() => Blockly.svgResize(workspace));
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
workspace.removeChangeListener(listener);
workspace.dispose();
workspaceRef.current = null;
};
}, [objectType.program]);
useEffect(() => {
const workspace = workspaceRef.current;
if (!workspace)
return;
try {
Blockly.Events.disable();
workspace.clear();
if (objectType.program)
Blockly.serialization.workspaces.load(JSON.parse(objectType.program), workspace);
} catch {
// corrupt program, start fresh
} finally {
Blockly.Events.enable();
}
}, [objectType.id, objectType.program]);
return <div className="script-editor">
<div ref={containerRef} className="blockly-container" />
</div>;
});
@@ -0,0 +1,16 @@
import { useThree } from "@react-three/fiber";
import type { Pos3 } from "../../types";
import { useLayoutEffect } from "react";
import { observer } from "mobx-react-lite";
export const CameraSync = observer(function ({ camera }: { camera: Pos3 }) {
const { camera: threeCamera } = useThree();
useLayoutEffect(() => {
threeCamera.position.set(camera.position[0], camera.position[1], camera.position[2]);
threeCamera.rotation.set(camera.look[0], camera.look[1], camera.look[2]);
threeCamera.updateProjectionMatrix();
});
return null;
});
@@ -0,0 +1,199 @@
import { Canvas } from '@react-three/fiber';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { observer } from 'mobx-react-lite';
import { useState, useCallback, useEffect, useRef } from 'react';
import { state } from '../../state';
import type { Voxel, V3 } from '../../types';
import { VoxelEditorScene } from './VoxelEditorScene';
import { IconArrowLeft, IconArrowBackUp, IconArrowForwardUp, IconPlus, IconTrash } from '@tabler/icons-react';
const PALETTE = [
'#ffffff', '#cccccc', '#999999', '#555555', '#222222',
'#e05555', '#e08844', '#e0cc44', '#55cc55', '#55cccc',
'#5588e0', '#aa55e0', '#a0522d', '#228b22', '#c06858',
'#e8b820', '#484040', '#d0c8b8', '#808080', '#181414',
];
type HistoryEntry =
| { type: 'add'; voxel: Voxel }
| { type: 'remove'; voxel: Voxel };
export const VoxelEditorPage = observer(function () {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [mode, setMode] = useState<'add' | 'remove'>('add');
const [color, setColor] = useState('#808080');
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const history = useRef<HistoryEntry[]>([]);
const future = useRef<HistoryEntry[]>([]);
const objectType = id ? state.world.getObjectTypeById(id) : undefined;
useEffect(() => {
return () => {
if (id)
state.worldEditor.refreshObjectTypeCaches(id);
};
}, [id]);
function pushHistory(entry: HistoryEntry) {
history.current.push(entry);
future.current = [];
setCanUndo(true);
setCanRedo(false);
}
const undo = useCallback(() => {
const entry = history.current.pop();
if (!entry || !id)
return;
if (entry.type === 'add') {
state.worldEditor.removeVoxelFromObjectType(id, entry.voxel.position);
} else {
state.worldEditor.addVoxelToObjectType(id, entry.voxel);
}
future.current.push(entry);
setCanUndo(history.current.length > 0);
setCanRedo(true);
}, [id]);
const redo = useCallback(() => {
const entry = future.current.pop();
if (!entry || !id)
return;
if (entry.type === 'add') {
state.worldEditor.addVoxelToObjectType(id, entry.voxel);
} else {
state.worldEditor.removeVoxelFromObjectType(id, entry.voxel.position);
}
history.current.push(entry);
setCanUndo(true);
setCanRedo(future.current.length > 0);
}, [id]);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (!e.ctrlKey)
return;
if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); }
if (e.key === 'y' || (e.key === 'z' && e.shiftKey)) { e.preventDefault(); redo(); }
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [undo, redo]);
function handleAdd(voxel: Voxel) {
if (!id)
return;
state.worldEditor.addVoxelToObjectType(id, voxel);
pushHistory({ type: 'add', voxel });
}
function handleRemove(position: V3) {
if (!id || !objectType)
return;
const voxel = objectType.voxels.find(v =>
v.position[0] === position[0] &&
v.position[1] === position[1] &&
v.position[2] === position[2]
);
if (!voxel)
return;
state.worldEditor.removeVoxelFromObjectType(id, position);
pushHistory({ type: 'remove', voxel });
}
if (!objectType) {
return (
<div style={{ color: 'white', padding: 24 }}>
Object type not found. <Link to="/editor" style={{ color: '#88aaff' }}>Back to editor</Link>
</div>
);
}
return (
<div style={{ position: 'fixed', inset: 0, background: '#1a1a2e', display: 'flex', flexDirection: 'column' }}>
{/* Top bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: '#11111f', flexShrink: 0 }}>
<button onClick={() => navigate(`/editor/objectType/${id}`)} style={btnStyle}>
<IconArrowLeft size={20} />
</button>
<span style={{ color: '#ccc', fontWeight: 600 }}>{objectType.name}</span>
<span style={{ color: '#666', fontSize: 13 }}>{objectType.voxels.length} voxels</span>
<div style={{ flex: 1 }} />
<button onClick={undo} disabled={!canUndo} style={{ ...btnStyle, opacity: canUndo ? 1 : 0.35 }}>
<IconArrowBackUp size={20} />
</button>
<button onClick={redo} disabled={!canRedo} style={{ ...btnStyle, opacity: canRedo ? 1 : 0.35 }}>
<IconArrowForwardUp size={20} />
</button>
</div>
{/* 3D canvas */}
<div style={{ flex: 1, position: 'relative' }}>
<Canvas camera={{ position: [12, 10, 12], fov: 50 }}>
<VoxelEditorScene
voxels={objectType.voxels}
mode={mode}
color={color}
typeId="stone"
onAdd={handleAdd}
onRemove={handleRemove}
/>
</Canvas>
</div>
{/* Bottom bar */}
<div style={{ background: '#11111f', padding: '10px 12px', flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Color palette */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{PALETTE.map(c => (
<button
key={c}
onClick={() => { setColor(c); setMode('add'); }}
style={{
width: 36, height: 36,
background: c,
borderRadius: 6,
border: c === color ? '3px solid white' : '3px solid #333',
cursor: 'pointer',
flexShrink: 0,
}}
/>
))}
</div>
{/* Mode toggle */}
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => setMode('add')}
style={{ ...btnStyle, flex: 1, padding: '12px 0', gap: 6, background: mode === 'add' ? '#2d6a2d' : '#2a2a3a', border: mode === 'add' ? '2px solid #4CAF50' : '2px solid #444' }}
>
<IconPlus size={18} /> Add
</button>
<button
onClick={() => setMode('remove')}
style={{ ...btnStyle, flex: 1, padding: '12px 0', gap: 6, background: mode === 'remove' ? '#6a2d2d' : '#2a2a3a', border: mode === 'remove' ? '2px solid #f44336' : '2px solid #444' }}
>
<IconTrash size={18} /> Remove
</button>
</div>
</div>
</div>
);
});
const btnStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#2a2a3a',
color: '#ccc',
border: '2px solid #444',
borderRadius: 8,
padding: '6px 10px',
cursor: 'pointer',
fontSize: 14,
gap: 4,
};
@@ -0,0 +1,125 @@
import { OrbitControls } from '@react-three/drei';
import { useState } from 'react';
import type { ThreeEvent } from '@react-three/fiber';
import type { Voxel, V3 } from '../../types';
type Props = {
voxels: Voxel[];
mode: 'add' | 'remove';
color: string;
typeId: string;
onAdd: (voxel: Voxel) => void;
onRemove: (position: V3) => void;
};
const FLOOR_Y = 0;
function adjPosition(pos: V3, nx: number, ny: number, nz: number): V3 {
return [pos[0] + Math.round(nx), pos[1] + Math.round(ny), pos[2] + Math.round(nz)];
}
function posEq(a: V3, b: V3) {
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
}
const DRAG_THRESHOLD = 5;
export function VoxelEditorScene({ voxels, mode, color, typeId, onAdd, onRemove }: Props) {
const [ghost, setGhost] = useState<V3 | null>(null);
function handleVoxelClick(e: ThreeEvent<MouseEvent>, voxel: Voxel) {
if (e.delta > DRAG_THRESHOLD)
return;
e.stopPropagation();
if (mode === 'remove') {
onRemove(voxel.position);
setGhost(null);
} else {
if (!e.face)
return;
const { x, y, z } = e.face.normal;
const pos = adjPosition(voxel.position, x, y, z);
onAdd({ typeId, position: pos, color });
}
}
function handleVoxelMove(e: ThreeEvent<PointerEvent>, voxel: Voxel) {
e.stopPropagation();
if (mode === 'remove') {
setGhost(voxel.position);
} else {
setGhost(null);
}
}
function handleFloorClick(e: ThreeEvent<MouseEvent>) {
if (e.delta > DRAG_THRESHOLD || mode !== 'add')
return;
e.stopPropagation();
const pos: V3 = [Math.floor(e.point.x), 0, Math.floor(e.point.z)];
onAdd({ typeId, position: pos, color });
}
function handleFloorMove(e: ThreeEvent<PointerEvent>) {
if (mode !== 'add')
return;
setGhost([Math.floor(e.point.x), 0, Math.floor(e.point.z)]);
}
const ghostOccupied = ghost && voxels.some(v => posEq(v.position, ghost));
return (
<>
<ambientLight intensity={0.7} />
<directionalLight position={[8, 12, 6]} intensity={1} castShadow />
<OrbitControls makeDefault />
{/* Floor plane */}
<mesh
rotation={[-Math.PI / 2, 0, 0]}
position={[0, FLOOR_Y, 0]}
onClick={handleFloorClick}
onPointerMove={handleFloorMove}
onPointerLeave={() => setGhost(null)}
>
<planeGeometry args={[40, 40]} />
<meshStandardMaterial color="#2a2a3a" />
</mesh>
<gridHelper args={[40, 40, '#555', '#333']} position={[0, FLOOR_Y, 0]} />
{/* Voxels */}
{voxels.map((v, i) => {
const isHovered = !!ghost && mode === 'remove' && posEq(v.position, ghost);
return (
<mesh
key={i}
position={[v.position[0] + 0.5, v.position[1] + 0.5, v.position[2] + 0.5]}
onClick={(e) => handleVoxelClick(e, v)}
onPointerMove={(e) => handleVoxelMove(e, v)}
onPointerLeave={() => setGhost(null)}
>
<boxGeometry />
<meshStandardMaterial
color={v.color ?? '#888888'}
opacity={isHovered ? 0.4 : 1}
transparent={isHovered}
/>
</mesh>
);
})}
{/* Ghost voxel preview */}
{ghost && mode === 'add' && !ghostOccupied && (
<mesh
position={[ghost[0] + 0.5, ghost[1] + 0.5, ghost[2] + 0.5]}
raycast={() => null}
>
<boxGeometry />
<meshStandardMaterial color={color} opacity={0.45} transparent />
</mesh>
)}
</>
);
}
+91
View File
@@ -0,0 +1,91 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 16px/100% var(--sans);
line-height: 1;
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 100%;
max-width: 100%;
margin: 0 auto;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
html,
body {
margin: 0;
overscroll-behavior: none;
}
// *:not(.panel.main) svg {
// fill: currentColor;
// display: inline-block;
// height: 1em;
// top: 0.125em;
// vertical-align: bottom;
// }
button,
input,
select,
textarea {
font: inherit;
}
* {
box-sizing: border-box;
}
// Blockly appends its dropdown/widget overlays to document.body.
// They need a z-index above .overlay-panels (15000) to be visible.
.blocklyDropDownDiv,
.blocklyWidgetDiv {
z-index: 16000 !important;
}
+7
View File
@@ -0,0 +1,7 @@
export const DEFAULT_JOYSTICK_VALUES = {
move: { x: 0, y: 0 },
jump: false,
look: { x: 0, y: 0 },
};
export const joystickValues = DEFAULT_JOYSTICK_VALUES;
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';
import './index.scss';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+42
View File
@@ -0,0 +1,42 @@
import type { VoxelType } from "../types";
const stone: VoxelType = {
id: 'stone',
name: 'Stone',
opacity: 1,
collidable: true,
color: 'white',
};
const dirt: VoxelType = {
id: 'dirt',
name: 'Dirt',
opacity: 0.8,
collidable: true,
color: '#302520',
};
const water: VoxelType = {
id: 'water',
name: 'Stone',
opacity: 0.7,
collidable: false,
color: '#80a0f0',
};
const glass: VoxelType = {
id: 'glass',
name: 'Glass',
opacity: 0.3,
collidable: true,
color: '#b0d8e8',
};
export const DEFAULT_VOXEL_TYPES = {
[stone.id]: stone,
[dirt.id]: dirt,
[water.id]: water,
[glass.id]: glass,
};
export const DEFAULT_VOXEL_TYPE = stone;
+112
View File
@@ -0,0 +1,112 @@
import type { Game, RuntimeGameScene, RuntimeScene, World } from "../types";
import type { GameEventContext, GameEventHandler, GeneratorEventHandler, InternalGameEventBus } from "../types/runtime/gameBus";
import { clone } from "../utils";
import { populateRuntimeScene } from "../utils/runtime";
import { ObjectApi } from "../utils/runtime/objectApi";
import type { GameScheduler, YieldCommand } from "./gameScheduler";
export class GameEventBus {
private handlers = new Map<string, GameEventHandler[]>();
on(event: string, handler: GameEventHandler): void {
console.log('subscribing to ' + event, handler);
let set = this.handlers.get(event);
if (!set) {
set = [];
this.handlers.set(event, set);
}
if (!set.includes(handler))
set.push(handler);
}
off(event: string, handler: GameEventHandler): void {
console.log('unubscribing from ' + event, handler);
const handlers = this.handlers.get(event);
if (!handlers)
return;
this.handlers.set(event, handlers.filter((h) => h !== handler));
}
emit(event: string, context: GameEventContext): void {
console.log('emitting ' + event);
this.handlers.get(event)
?.forEach((handler) => handler(context));
}
}
export class GameFactory {
public static create(world: World): Game {
const scene = populateRuntimeScene(clone(world.initialScene), world);
const game = {
paused: false,
time: 0,
scene: GameFactory.initGameScene(scene),
};
return game;
}
public static load(): Game | undefined {
const json = localStorage.getItem("game");
if (json) {
return JSON.parse(json) as Game;
}
}
public static save(game: Game): void {
localStorage.setItem("game", JSON.stringify(game));
}
private static initGameScene(scene: RuntimeScene): RuntimeGameScene {
return scene as RuntimeGameScene;
}
public static evalGameScripts(
world: World,
scene: RuntimeGameScene,
eventBus: GameEventBus,
scheduler: GameScheduler,
): void {
const handlerWrappers = new WeakMap<GeneratorEventHandler, GameEventHandler>();
const wait = (seconds: number): YieldCommand => ({ type: 'waitSeconds', seconds });
const waitFrames = (frames: number): YieldCommand => ({ type: 'waitFrames', frames });
const waitUntil = (condition: () => boolean): YieldCommand => ({ type: 'waitUntil', condition });
const internalBus: InternalGameEventBus = {
onGameEvent(event, genFn) {
const wrapper: GameEventHandler = (ctx) => scheduler.startCoroutine(genFn(ctx) as Generator<YieldCommand, void, void>);
handlerWrappers.set(genFn, wrapper);
eventBus.on(event, wrapper);
},
offGameEvent(event, genFn) {
const wrapper = handlerWrappers.get(genFn);
if (wrapper) {
eventBus.off(event, wrapper);
handlerWrappers.delete(genFn);
}
},
};
try {
Object.values(world.objectTypes)
.filter((ot) => ot.javascript)
.forEach((ot) => {
const gameScript = eval(`(function gameScript({onGameEvent, offGameEvent, wait, waitFrames, waitUntil}, {api, object, objectType}) {${ot.javascript}})`);
Object.values(scene.objects)
.filter((o) => o.typeId == ot.id)
.forEach((o) => {
const api = new ObjectApi(o, ot, world, scene);
gameScript({ ...internalBus, wait, waitFrames, waitUntil }, { api, object: o, objectType: ot });
});
});
}
catch (err) {
console.log('Error running game script:\n' + err);
}
}
}
+66
View File
@@ -0,0 +1,66 @@
export type YieldCommand =
| { type: 'waitSeconds'; seconds: number }
| { type: 'waitFrames'; frames: number }
| { type: 'waitUntil'; condition: () => boolean };
type Coroutine =
& { gen: Generator<YieldCommand, void, void> }
& (| { wakeType: 'time'; remaining: number }
| { wakeType: 'frames'; remaining: number }
| { wakeType: 'condition'; test: () => boolean });
export class GameScheduler {
private coroutines: Coroutine[] = [];
tick(deltaTime: number): void {
const next: Coroutine[] = [];
for (const co of this.coroutines) {
if (this.isReady(co))
this.step(co, next);
else
next.push(this.countdown(co, deltaTime));
}
this.coroutines = next;
}
startCoroutine(gen: Generator<YieldCommand, void, void>): void {
const result = gen.next();
if (!result.done)
this.coroutines.push(this.make(gen, result.value));
}
reset(): void { this.coroutines = []; }
private isReady(co: Coroutine): boolean {
switch (co.wakeType) {
case 'time': return co.remaining <= 0;
case 'frames': return co.remaining <= 0;
case 'condition': return co.test();
}
}
private countdown(co: Coroutine, deltaTime: number): Coroutine {
switch (co.wakeType) {
case 'time': return { ...co, remaining: co.remaining - deltaTime };
case 'frames': return { ...co, remaining: co.remaining - 1 };
default: return co;
}
}
private step(co: Coroutine, list: Coroutine[]): void {
const result = co.gen.next();
if (!result.done)
list.push(this.make(co.gen, result.value));
}
private make(gen: Generator<YieldCommand, void, void>, cmd: YieldCommand): Coroutine {
switch (cmd.type) {
case 'waitSeconds':
return { gen, wakeType: 'time', remaining: cmd.seconds };
case 'waitFrames':
return { gen, wakeType: 'frames', remaining: cmd.frames };
case 'waitUntil':
return { gen, wakeType: 'condition', test: cmd.condition };
}
}
}
@@ -0,0 +1,18 @@
import type { Voxel } from "../../types";
export function terrainXZ(width: number, length: number): Voxel[] {
const vs = Array(width)
.fill(0)
.flatMap((_, x) =>
Array(length)
.fill(0)
.map((_, z) => ({
typeId: 'dirt',
position: [x - Math.floor(width / 2), 0, z - Math.floor(length / 2)],
color: 'green',
} as Voxel))
);
return vs;
}
+62
View File
@@ -0,0 +1,62 @@
import type { Voxel } from "../../types";
const G = '#808080'; // gray fur
const L = '#d0c8b8'; // light beige-gray (snout / face)
const D = '#484040'; // dark gray (outer ear)
const E = '#e8b820'; // amber (eyes)
const N = '#181414'; // near-black (nose, pupils)
const P = '#c06858'; // pink (inner ear)
function v(x: number, y: number, z: number, color: string): Voxel {
return { typeId: 'stone', position: [x, y, z], color };
}
// Wolf faces -Z (nose at Z=-3). Head spans X:-3..3, Y:0..5, Z:-3..3.
export const wolf: Voxel[] = [
// ── Snout tip + nose Z=-3 ────────────────────────────────────────────
v(-1,1,-3,L), v(0,1,-3,L), v(1,1,-3,L),
v(-1,0,-3,L), v(0,0,-3,L), v(1,0,-3,L),
v(-1,2,-3,N), v(0,2,-3,N), v(1,2,-3,N), // nose
// ── Front face + pupils + snout Z=-2 ────────────────────────────────
v(-2,2,-2,L), v(-1,2,-2,L), v(0,2,-2,L), v(1,2,-2,L), v(2,2,-2,L),
v(-2,3,-2,G), v(-1,3,-2,N), v(0,3,-2,G), v(1,3,-2,N), v(2,3,-2,G),
v(-2,4,-2,G), v(-1,4,-2,G), v(0,4,-2,G), v(1,4,-2,G), v(2,4,-2,G),
v(-1,0,-2,L), v(0,0,-2,L), v(1,0,-2,L),
v(-1,1,-2,L), v(0,1,-2,L), v(1,1,-2,L),
// ── Face + amber eyes + snout Z=-1 ──────────────────────────────────
v(-2,2,-1,L), v(-1,2,-1,L), v(0,2,-1,L), v(1,2,-1,L), v(2,2,-1,L),
v(-2,3,-1,G), v(-1,3,-1,E), v(0,3,-1,G), v(1,3,-1,E), v(2,3,-1,G),
v(-2,4,-1,G), v(-1,4,-1,G), v(0,4,-1,G), v(1,4,-1,G), v(2,4,-1,G),
v(-1,0,-1,L), v(0,0,-1,L), v(1,0,-1,L),
v(-1,1,-1,L), v(0,1,-1,L), v(1,1,-1,L),
// ── Face + ears Z=0 ──────────────────────────────────────────────────
v(-2,2,0,L), v(-1,2,0,L), v(0,2,0,L), v(1,2,0,L), v(2,2,0,L),
v(-2,3,0,G), v(-1,3,0,G), v(0,3,0,G), v(1,3,0,G), v(2,3,0,G),
// ears: dark outer, pink inner
v(-3,4,0,D), v(-2,4,0,P), v(-1,4,0,G), v(0,4,0,G), v(1,4,0,G), v(2,4,0,P), v(3,4,0,D),
v(-3,5,0,D), v(-2,5,0,P), v(-1,5,0,G), v(0,5,0,G), v(1,5,0,G), v(2,5,0,P), v(3,5,0,D),
// snout start
v(-1,0,0,L), v(0,0,0,L), v(1,0,0,L),
v(-1,1,0,L), v(0,1,0,L), v(1,1,0,L),
// ── Skull + ear bases Z=1 ────────────────────────────────────────────
v(-2,2,1,G), v(-1,2,1,G), v(0,2,1,G), v(1,2,1,G), v(2,2,1,G),
v(-2,3,1,G), v(-1,3,1,G), v(0,3,1,G), v(1,3,1,G), v(2,3,1,G),
v(-3,4,1,G), v(-2,4,1,G), v(-1,4,1,G), v(0,4,1,G), v(1,4,1,G), v(2,4,1,G), v(3,4,1,G),
v(-2,5,1,G), v(-1,5,1,G), v(0,5,1,G), v(1,5,1,G), v(2,5,1,G),
v(-3,5,1,D), v(3,5,1,D),
// ── Skull Z=2 ───────────────────────────────────────────────────────
v(-2,2,2,G), v(-1,2,2,G), v(0,2,2,G), v(1,2,2,G), v(2,2,2,G),
v(-2,3,2,G), v(-1,3,2,G), v(0,3,2,G), v(1,3,2,G), v(2,3,2,G),
v(-2,4,2,G), v(-1,4,2,G), v(0,4,2,G), v(1,4,2,G), v(2,4,2,G),
v(-1,5,2,G), v(0,5,2,G), v(1,5,2,G),
// ── Back of skull Z=3 ───────────────────────────────────────────────
v(-1,2,3,G), v(0,2,3,G), v(1,2,3,G),
v(-1,3,3,G), v(0,3,3,G), v(1,3,3,G),
v(-1,4,3,G), v(0,4,3,G), v(1,4,3,G),
];
+3
View File
@@ -0,0 +1,3 @@
export class Scene {
}
+34
View File
@@ -0,0 +1,34 @@
import { type RuntimeWorld, type World } from "../types";
import { depopulateRuntimeWorld, populateRuntimeWorld } from "../utils/runtime";
import { DEFAULT_VOXEL_TYPES } from "./defaultVoxelTypes";
export class WorldFactory {
public static create(): RuntimeWorld {
return populateRuntimeWorld({
objectTypes: {},
voxelTypes: DEFAULT_VOXEL_TYPES,
initialScene: {
objects: {},
},
gameRules: {
gravity: true,
},
editorCamera: {
position: [0, 5, 10],
look: [0, 0, 0],
},
});
}
public static load(): RuntimeWorld | undefined {
const json = localStorage.getItem("world");
if (json) {
return populateRuntimeWorld(JSON.parse(json) as World);
}
}
public static save(world: RuntimeWorld): void {
localStorage.setItem("world", JSON.stringify(depopulateRuntimeWorld(world)));
}
}
+139
View File
@@ -0,0 +1,139 @@
import { makeAutoObservable, reaction, toJS } from "mobx";
import type { WorldState } from "./worldState";
import type { Game, Pos3, RuntimeGameScene } from "../types";
import type { CameraProps } from "@react-three/fiber";
import { GameEventBus, GameFactory } from "../model/gameFactory";
import { GameScheduler } from "../model/gameScheduler";
import type { GameEventContext } from "../types/runtime/gameBus";
export class GameState {
private readonly world: WorldState;
public isPaused: boolean = false;
public time: number = 0;
public isFirstTick: boolean;
public scene: RuntimeGameScene;
private eventBus: GameEventBus;
private scheduler: GameScheduler;
private startAutoSave() {
return reaction(
() => (this.asGame),
(data) => this.saveData(data),
{ delay: 5000 }
);
}
// @ts-expect-error -- for future use
private withoutAutoSave(fn: () => void) {
this._stopAutoSave();
fn();
this._stopAutoSave = this.startAutoSave();
}
private _stopAutoSave: () => void = () => { };
constructor(world: WorldState) {
this.world = world;
const rawWorld = toJS(this.world.data);
const game = GameFactory.create(rawWorld);
this.isPaused = game.paused;
this.time = game.time;
this.isFirstTick = true;
this.scene = game.scene;
const eventBus = new GameEventBus();
this.eventBus = eventBus;
this.scheduler = new GameScheduler();
this._stopAutoSave = this.startAutoSave();
makeAutoObservable(this);
GameFactory.evalGameScripts(rawWorld, this.scene, eventBus, this.scheduler);
}
public emitEvent(event: string, context: Omit<GameEventContext, 'world' | 'scene'> = {}) {
this.eventBus.emit(
event,
{
...context,
world: this.world.data,
scene: this.scene,
},
);
}
public get asGame(): Game {
return {
paused: toJS(this.isPaused),
time: toJS(this.time),
scene: toJS(this.scene),
};
}
public setGame(value: Game) {
this.isPaused = value.paused;
this.time = value.time;
this.scene = value.scene;
}
public setIsPaused(value: boolean) {
this.isPaused = value;
}
public get camera(): Pos3 {
return this.world.data.editorCamera;
}
public get cameraAsThree(): CameraProps {
const cam = this.camera;
return {
position: cam.position,
fov: 50,
rotation: cam.look,
};
}
// public load() {
// console.log('Loading game...');
// this.withoutAutoSave(() => {
// this.data = GameFactory.load() ?? GameFactory.create(this.world.data);
// });
// }
private saveData(data: Game): void {
console.log('Saving game...');
const stack = new Error('Saving game...').stack!.split('\n').slice(1);
const { ...debug } = toJS(data);
console.dir({ stack, debug });
GameFactory.save(data);
}
public save(): void {
this.saveData(this.asGame);
}
public resume(): void {
this.isPaused = false;
}
public pause(): void {
this.isPaused = true;
}
public tick(deltaTime: number): void {
if (this.isPaused)
return;
this.scheduler.tick(deltaTime);
this.time += deltaTime;
if (this.isFirstTick) {
this.isFirstTick = false;
this.emitEvent('game_start');
}
}
}
+1
View File
@@ -0,0 +1 @@
export * from './rootState';
+127
View File
@@ -0,0 +1,127 @@
import { makeAutoObservable } from "mobx";
import { state } from "./rootState";
import type { ReactNode } from "react";
import { IconRun, IconTrash, IconPlus, IconCubePlus, IconCubeOff, IconEdit } from '@tabler/icons-react';
export type MenuNodeAction = {
id: string;
content: ReactNode;
tooltip: string;
onClick?: () => void;
link?: string;
}
export type MenuNode = {
id: string;
title: ReactNode;
className?: string;
actions?: MenuNodeAction[];
onClick?: () => void;
selected?: () => boolean;
children?: MenuNode[];
}
export class MenuState {
constructor() {
makeAutoObservable(this);
}
private get editorObjectTypesMenu(): MenuNode[] {
const editor = state.worldEditor;
const scene = editor.scene;
return Object.values(state.world.data.objectTypes)
.map((ot) => {
const children = Object.values(scene.objects)
.filter((o) => o.typeId === ot.id)
.map((o, idx) => {
const isPlayer = scene.playerObjectId === o.id;
const actions: MenuNodeAction[] = [];
if (!isPlayer) {
actions.push({
id: 'delete-object-instance',
content: <IconTrash size="1em" />,
tooltip: 'Delete the object',
onClick: () => { editor.deleteObject(o.id); },
});
actions.push({
id: 'control-object-instance-by-player',
content: <IconRun size="1em" />,
tooltip: 'Mark as player',
onClick: () => { scene.playerObjectId = o.id; },
});
}
return {
id: `o-${o.id}`,
title: <>
{isPlayer && <IconRun size="1em" />}
{`${ot.name} ${idx + 1}`}
</>,
className: isPlayer ? 'player-controlled-object' : undefined,
actions,
onClick: () => { state.worldEditor.setSelectedObject({ id: o.id, editMode: 'translate' }); },
selected: () => state.worldEditor.selection?.type === 'object' && state.worldEditor.selection?.id === o.id,
} as MenuNode;
});
return {
id: `ot-${ot.id}`,
title: `${ot.name} (${children.length ? children.length : 'no'} instance${children.length != 1 ? 's' : ''})`,
actions: [
{
id: 'create-object-instance',
content: <IconPlus size="1em" />,
tooltip: 'Create new object instance',
onClick: () => { editor.addObjectInstanceAtRandomPosition(ot.id); },
},
{
id: 'edit-object-type-voxels',
content: <IconEdit size="1em" />,
tooltip: 'Edit object type voxels',
link: `/editor/objectType/${ot.id}/voxels`,
},
{
id: 'delete-object-type',
content: <IconCubeOff size="1em" />,
tooltip: 'Delete object type',
onClick: () => { editor.deleteObjectType(ot.id); },
},
],
onClick: () => { editor.setSelectedObjectType({ id: ot.id, editMode: 'scripts' }); },
selected: () => editor.selection?.type === 'objectType' && editor.selection?.id === ot.id,
children,
} as MenuNode;
});
}
private get editorMenu(): MenuNode[] {
return [
{
id: 'editor-scene-menu',
title: 'Scene',
onClick: () => { state.worldEditor.resetSelection(); },
selected: () => !state.worldEditor.selection,
children: this.editorObjectTypesMenu,
actions: [{
id: 'create-object-type',
content: <IconCubePlus size="1em" />,
tooltip: 'Create new object type',
onClick: () => { state.worldEditor.addObjectType(); },
}],
}
];
}
public get nodes(): MenuNode[] {
return [
...this.editorMenu,
];
}
public nodeContainsSelected(node: MenuNode): boolean {
return !!(node.selected?.() || node.children?.some((child) => this.nodeContainsSelected(child)));
}
}
+64
View File
@@ -0,0 +1,64 @@
import { makeAutoObservable } from "mobx";
import { WorldState } from "./worldState";
import { WorldEditorState } from "./worldEditorState";
import { GameState } from "./gameState";
import { MenuState } from "./menuState";
import type { RuntimeObjectInstance } from "../types";
export type RenderInfo = {
calls: number,
triangles: number,
shaders: number,
geometries: number,
textures: number,
}
export class RootState {
public readonly world = new WorldState();
public readonly worldEditor: WorldEditorState;
public game: GameState | undefined;
public renderInfo: RenderInfo | undefined;
public readonly menu = new MenuState();
constructor() {
this.worldEditor = new WorldEditorState(this.world);
makeAutoObservable(
this,
{
world: false,
menu: false,
},
);
}
public startGame(): void {
state.worldEditor.resetSelection();
if (this.game)
this.stopGame();
this.game = new GameState(this.world);
}
public stopGame(): void {
if (!this.game)
return;
this.game.pause();
this.game = undefined;
}
public setRenderInfo(value: RenderInfo | undefined) {
this.renderInfo = value;
}
public markObjectAsPlayer(object: RuntimeObjectInstance) {
if (this.game)
this.game.scene.playerObjectId = object.id;
else
this.world.data.initialScene.playerObjectId = object.id;
}
}
export const state = new RootState();
state.world.load();
+219
View File
@@ -0,0 +1,219 @@
import { makeAutoObservable, runInAction } from "mobx";
import type { WorldState } from "./worldState";
import { type ObjectInstance, type Pos3, type R3, type V3, type RuntimeScene, type ObjectType, type Voxel } from "../types";
import { createObjectInstance } from "../utils/object";
import { randomId } from "../utils";
import { state } from "./rootState";
import { populateRuntimeObject, computeBoundingBox } from "../utils/runtime";
import { DEFAULT_VOXEL_TYPE } from "../model/defaultVoxelTypes";
import { getObjectVoxelGroups } from "../utils/graphics/voxelGroup";
import { buildObjectTrimesh } from "../utils/graphics/mesh";
export const ObjectEditModeEnum = [
'translate',
'rotate',
'scale',
] as const;
type ObjectEditModeTuple = typeof ObjectEditModeEnum;
export type ObjectEditMode = ObjectEditModeTuple[number];
export function nextObjectEditMode(mode: ObjectEditMode | undefined): ObjectEditMode {
if (mode === undefined)
return ObjectEditModeEnum[0];
const idx = ObjectEditModeEnum.indexOf(mode);
return ObjectEditModeEnum[(idx + 1) % ObjectEditModeEnum.length];
}
export const ObjectTypeEditModeEnum = [
'scripts',
] as const;
type ObjectTypeEditModeTuple = typeof ObjectTypeEditModeEnum;
export type ObjectTypeEditMode = ObjectTypeEditModeTuple[number];
export function nextObjectTypeEditMode(mode: ObjectTypeEditMode | undefined): ObjectTypeEditMode {
if (mode === undefined)
return ObjectTypeEditModeEnum[0];
const idx = ObjectTypeEditModeEnum.indexOf(mode);
return ObjectTypeEditModeEnum[(idx + 1) % ObjectTypeEditModeEnum.length];
}
export type SelectedObject = {
type: 'object',
id: string;
editMode: ObjectEditMode;
}
export type SelectedObjectType = {
type: 'objectType',
id: string;
editMode: ObjectTypeEditMode;
}
export type Selection = SelectedObject | SelectedObjectType;
export class WorldEditorState {
private readonly world: WorldState;
public selection: Selection | undefined;
constructor(world: WorldState) {
this.world = world;
makeAutoObservable(this);
}
public get isEnabled(): boolean {
return !state.game;
}
public setCamera(value: Pos3): void {
this.world.data.editorCamera = value;
}
public setSelection(value: Selection | undefined): void {
this.selection = value;
}
public setSelectedObject(value: Omit<SelectedObject, 'type'> | undefined): void {
this.setSelection(value
? {
type: 'object',
...value,
}
: undefined,
);
}
public setSelectedObjectType(value: Omit<SelectedObjectType, 'type'> | undefined): void {
this.setSelection(value
? {
type: 'objectType',
...value,
}
: undefined,
);
}
public resetSelection() {
this.setSelection(undefined);
}
public get scene(): RuntimeScene {
return this.world.data.initialScene;
}
public get camera(): Pos3 {
return this.world.data.editorCamera;
}
public setObjectTransform(id: string, position: V3, rotation: R3, scale: V3): void {
const obj = this.scene.objects[id];
if (!obj)
return;
runInAction(() => { // all in one go
obj.position = position;
obj.rotation = rotation;
obj.scale = scale;
});
}
public addObjectInstanceAtRandomPosition(typeId: string): ObjectInstance {
return this.addObjectInstance(
typeId,
{ x: Math.random() * 3, y: Math.random() * 3, z: Math.random() * 1 },
);
}
public addObjectInstance(
typeId: string,
pos: { x: number; y: number; z: number; },
): ObjectInstance {
const obj = populateRuntimeObject(createObjectInstance(randomId(), typeId), this.world.data);
obj.position = [pos.x, pos.y, pos.z];
obj.rotation = [0, 0, 0];
this.scene.objects[obj.id] = obj;
this.setSelectedObject({ id: obj.id, editMode: 'translate' });
return obj;
}
public deleteObject(objectId: string) {
if (this.selection?.type === 'object' && this.selection.id === objectId)
this.resetSelection();
delete (this.scene.objects[objectId]);
}
public addObjectType(): ObjectType {
const objectType: ObjectType = {
id: randomId(),
name: 'Unnamed',
voxels: [
{
typeId: DEFAULT_VOXEL_TYPE.id,
position: [0, 0, 0],
}
],
};
this.world.data.objectTypes[objectType.id] = objectType;
this.setSelectedObjectType({ id: objectType.id, editMode: 'scripts' });
return objectType;
}
public addVoxelToObjectType(typeId: string, voxel: Voxel): void {
const objectType = this.world.getObjectTypeById(typeId);
if (!objectType)
return;
const occupied = objectType.voxels.some(v =>
v.position[0] === voxel.position[0] &&
v.position[1] === voxel.position[1] &&
v.position[2] === voxel.position[2]
);
if (!occupied)
objectType.voxels.push(voxel);
}
public removeVoxelFromObjectType(typeId: string, position: V3): void {
const objectType = this.world.getObjectTypeById(typeId);
if (!objectType)
return;
const idx = objectType.voxels.findIndex(v =>
v.position[0] === position[0] &&
v.position[1] === position[1] &&
v.position[2] === position[2]
);
if (idx !== -1)
objectType.voxels.splice(idx, 1);
}
public refreshObjectTypeCaches(typeId: string): void {
const objectType = this.world.getObjectTypeById(typeId);
if (!objectType)
return;
const world = this.world.data;
const now = Date.now();
for (const obj of Object.values(this.scene.objects)) {
if (obj.typeId !== typeId)
continue;
obj.cache.voxelGroups = getObjectVoxelGroups(objectType, world.voxelTypes);
obj.cache.colliderMesh = buildObjectTrimesh(objectType, world.voxelTypes);
obj.cache.boundingBox = computeBoundingBox(objectType);
obj.cache.updatedAt = now;
}
}
public deleteObjectType(typeId: string) {
if (this.selection?.type === 'objectType' && this.selection.id === typeId)
this.resetSelection();
for (const id in this.scene.objects)
if (this.scene.objects[id].typeId === typeId)
this.deleteObject(id);
delete (this.world.data.objectTypes[typeId]);
}
}
+125
View File
@@ -0,0 +1,125 @@
import { makeAutoObservable, reaction, toJS } from "mobx";
import { WorldFactory } from "../model/worldFactory";
import { type ObjectInstance, type ObjectType, type World, type VoxelType, type RuntimeWorld } from "../types";
import { state } from "./rootState";
import { DEFAULT_VOXEL_TYPES } from "../model/defaultVoxelTypes";
import { wolf } from "../model/objectPrefabs/wolf";
import { terrainXZ } from "../model/objectPrefabs/terrain";
import { populateRuntimeWorld } from "../utils/runtime";
export class WorldState {
public data: RuntimeWorld = WorldFactory.create();
private startAutoSave() {
return reaction(() => toJS(this.data), (data) => this.saveData(data), { delay: 500 });
}
private withoutAutoSave(fn: () => void) {
this._stopAutoSave();
fn();
this._stopAutoSave = this.startAutoSave();
}
private _stopAutoSave: () => void = () => { };
constructor() {
makeAutoObservable(this);
this._stopAutoSave = this.startAutoSave();
}
public reset() {
console.log('Resetting world...');
this.withoutAutoSave(() => {
this.data = WorldFactory.create();
});
state.worldEditor.resetSelection();
}
public loadMock() {
console.log('Mocking world...');
const objects = Array(3).fill(0)
.map((_, idx) => ({
id: `obj${idx}`,
typeId: 'wolf',
physics: true,
gravityScale: 1,
position: [idx * 10 - 10, 5, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
} as ObjectInstance));
const objectMap = Object.fromEntries(
objects.map((obj) => [obj.id, obj]),
) as Record<string, ObjectInstance>;
this.data = populateRuntimeWorld({
objectTypes: {
wolf: {
id: 'wolf',
name: 'Wolf',
voxels: wolf,
},
terrain: {
id: 'terrain',
name: 'Terrain',
voxels: terrainXZ(50, 50),
}
},
voxelTypes: DEFAULT_VOXEL_TYPES,
editorCamera: {
position: [14, 17, -25],
look: [-2.5, 0.31, 3],
},
initialScene: {
objects: {
terrain: {
id: 'terrain',
typeId: 'terrain',
physics: false, // pinned
gravityScale: 0, // pinned
position: [0, -1, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
...objectMap,
},
playerObjectId: 'obj1',
},
gameRules: {
gravity: true,
},
});
console.log(objects);
state.worldEditor.resetSelection();
}
public load() {
console.log('Loading world...');
this.withoutAutoSave(() => {
this.data = WorldFactory.load() ?? WorldFactory.create();
});
state.worldEditor.resetSelection();
}
private saveData(data: World): void {
console.log('Saving world...');
const stack = new Error('Saving world...').stack!.split('\n').slice(1);
const { voxelTypes: _vts, initialScene: _is, ...debug } = toJS(data);
console.dir({ stack, debug });
WorldFactory.save(toJS(this.data));
}
public save(): void {
this.saveData(this.data);
}
public getObjectTypeById(id: string): ObjectType | undefined {
return this.data.objectTypes[id];
}
public getVoxelTypeById(id: string): VoxelType | undefined {
return this.data.voxelTypes[id];
}
}
+10
View File
@@ -0,0 +1,10 @@
import type { Vector3Tuple } from "three";
export type V3 = Vector3Tuple;
export type R3 = V3; // Euler angles in radians, order XYZ
export type Pos3 = {
position: V3;
look: R3;
}
+3
View File
@@ -0,0 +1,3 @@
export * from './3d';
export * from './model';
export * from './runtime';
+7
View File
@@ -0,0 +1,7 @@
import type { RuntimeGameScene } from "./scene";
export type Game = {
paused: boolean;
time: number;
scene: RuntimeGameScene;
}
+3
View File
@@ -0,0 +1,3 @@
export type GameRules = {
gravity: boolean;
}
+7
View File
@@ -0,0 +1,7 @@
export * from './object';
export * from './scene';
export * from './world';
export * from './gameRules';
export * from './game';
export * from './voxel';
export * from './runtime';
+32
View File
@@ -0,0 +1,32 @@
import type { R3, V3 } from "../3d";
import type { Runtime } from "./runtime";
import type { Voxel } from "./voxel";
export type ObjectType = {
id: string;
name: string;
voxels: Voxel[];
program?: string;
javascript?: string;
}
export type ObjectInstance = {
id: string;
typeId: string;
physics: boolean;
gravityScale: number;
position: V3;
rotation: R3;
scale: V3;
}
export type GameObjectData = {
version: number; // increment to force three and physics changes
linearVelocity: V3;
angularVelocity: R3;
}
export type GameObjectInstance = ObjectInstance & GameObjectData;
export type RuntimeObjectInstance = Runtime<ObjectInstance>;
export type RuntimeGameObjectInstance = Runtime<GameObjectInstance>;
+17
View File
@@ -0,0 +1,17 @@
import type { V3 } from "../3d";
import type { VoxelGroup } from "../runtime";
import type { ObjectInstance } from "./object";
export type ObjectInstanceRuntimeData = {
cache: {
voxelGroups: VoxelGroup[];
colliderMesh: [Float32Array, Uint32Array] | null;
boundingBox: { min: V3; max: V3 };
updatedAt: number;
};
pendingActions: {
impulse?: { direction: V3, amplitude: number };
}
}
export type Runtime<T extends ObjectInstance> = T & ObjectInstanceRuntimeData;
+18
View File
@@ -0,0 +1,18 @@
import type { GameObjectInstance, ObjectInstance, RuntimeGameObjectInstance, RuntimeObjectInstance } from "./object";
export type Scene = {
objects: Record<string, ObjectInstance>;
playerObjectId: string | undefined;
}
export type RuntimeScene = Scene & {
objects: Record<string, RuntimeObjectInstance>;
}
export type GameScene = Scene & {
objects: Record<string, GameObjectInstance>;
}
export type RuntimeGameScene = Scene & {
objects: Record<string, RuntimeGameObjectInstance>;
}
+17
View File
@@ -0,0 +1,17 @@
import type { V3 } from "../3d";
export type VoxelType = {
id: string;
name: string;
color: string;
opacity: number;
collidable: boolean;
}
export type Voxel = {
typeId: string;
position: V3;
color?: string;
opacity?: number;
}
+17
View File
@@ -0,0 +1,17 @@
import type { GameRules } from "./gameRules";
import type { ObjectType } from "./object";
import type { RuntimeScene, Scene } from "./scene";
import type { Pos3 } from "../3d";
import type { VoxelType } from "./voxel";
export type World = {
objectTypes: Record<string, ObjectType>;
voxelTypes: Record<string, VoxelType>;
editorCamera: Pos3;
initialScene: Scene;
gameRules: GameRules;
}
export type RuntimeWorld = World & {
initialScene: RuntimeScene;
}
+16
View File
@@ -0,0 +1,16 @@
import type { RuntimeGameObjectInstance, Scene, World } from "../model";
export type GameEventContext = {
world: World;
scene: Scene;
object?: RuntimeGameObjectInstance;
}
export type GameEventHandler = (context: GameEventContext) => void;
export type GeneratorEventHandler = (context: GameEventContext) => Generator<unknown, void, void>;
export type InternalGameEventBus = {
onGameEvent: (event: string, handler: GeneratorEventHandler) => void;
offGameEvent: (event: string, handler: GeneratorEventHandler) => void;
}
+1
View File
@@ -0,0 +1 @@
export * from './voxelGroup';
@@ -0,0 +1,9 @@
import type { V3 } from "../3d";
export type VoxelGroup = {
id: string;
color: string;
opacity: number;
positions: V3[];
};
+41
View File
@@ -0,0 +1,41 @@
import type { ObjectType, VoxelType } from "../../types";
export function buildObjectTrimesh(objectType: ObjectType, voxelTypes: Record<string, VoxelType>): [Float32Array, Uint32Array] | null {
const collidable = objectType.voxels.filter((v) => voxelTypes[v.typeId]?.collidable !== false);
if (!collidable.length)
return null;
const n = collidable.length;
const verts = new Float32Array(n * 8 * 3);
const idxs = new Uint32Array(n * 36);
for (let i = 0; i < n; i++) {
const p = collidable[i].position;
const vb = i * 8;
// 8 corners of unit box at position p (no +0.5 — group transform handles offset)
verts.set([
p[0], p[1], p[2],
p[0] + 1, p[1], p[2],
p[0], p[1] + 1, p[2],
p[0] + 1, p[1] + 1, p[2],
p[0], p[1], p[2] + 1,
p[0] + 1, p[1], p[2] + 1,
p[0], p[1] + 1, p[2] + 1,
p[0] + 1, p[1] + 1, p[2] + 1,
], vb * 3);
// 12 triangles (CCW outward normals)
idxs.set([
vb, vb + 2, vb + 3, vb, vb + 3, vb + 1, // -Z
vb + 4, vb + 5, vb + 7, vb + 4, vb + 7, vb + 6, // +Z
vb, vb + 1, vb + 5, vb, vb + 5, vb + 4, // -Y
vb + 2, vb + 6, vb + 7, vb + 2, vb + 7, vb + 3, // +Y
vb, vb + 4, vb + 6, vb, vb + 6, vb + 2, // -X
vb + 1, vb + 3, vb + 7, vb + 1, vb + 7, vb + 5, // +X
], i * 36);
}
return [verts, idxs];
}
@@ -0,0 +1,20 @@
import type { ObjectType, VoxelGroup, VoxelType } from "../../types";
export function getObjectVoxelGroups(
object: ObjectType,
voxelTypes: Record<string, VoxelType>,
): VoxelGroup[] {
const map = new Map<string, VoxelGroup>();
for (const idx in object.voxels) {
const v = object.voxels[idx];
const vt = voxelTypes[v.typeId];
const color = (v.color ?? vt?.color) ?? 'white';
const opacity = (v.opacity ?? vt?.opacity) ?? 1;
const key = `${color}-${opacity}`;
if (!map.has(key))
map.set(key, { id: key, color, opacity, positions: [] });
const p = v.position;
map.get(key)!.positions.push([p[0] + 0.5, p[1] + 0.5, p[2] + 0.5]);
}
return [...map.values()];
}
+19
View File
@@ -0,0 +1,19 @@
import { v4 as uuid } from 'uuid';
import type { V3 } from '../types';
import { type Vector3Object } from '@react-three/rapier';
export function clone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
export function randomId(): string {
return uuid();
}
export function v3toRapier(value: V3): Vector3Object {
return {
x: value[0],
y: value[1],
z: value[2],
};
}
+13
View File
@@ -0,0 +1,13 @@
import type { ObjectInstance } from "../types";
export function createObjectInstance(id: string, typeId: string): ObjectInstance {
return {
id,
typeId: typeId,
physics: true,
gravityScale: 1,
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
};
}
+3
View File
@@ -0,0 +1,3 @@
export * from './world';
export * from './scene';
export * from './object';
+39
View File
@@ -0,0 +1,39 @@
import { getObjectVoxelGroups } from "../graphics/voxelGroup";
import type { ObjectInstance, ObjectType, RuntimeObjectInstance, V3, World } from "../../types";
import { buildObjectTrimesh } from "../graphics/mesh";
export function computeBoundingBox(objectType: ObjectType): { min: V3; max: V3 } {
if (!objectType.voxels.length)
return { min: [0, 0, 0], max: [1, 1, 1] };
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (const v of objectType.voxels) {
minX = Math.min(minX, v.position[0]);
minY = Math.min(minY, v.position[1]);
minZ = Math.min(minZ, v.position[2]);
maxX = Math.max(maxX, v.position[0] + 1);
maxY = Math.max(maxY, v.position[1] + 1);
maxZ = Math.max(maxZ, v.position[2] + 1);
}
return { min: [minX, minY, minZ], max: [maxX, maxY, maxZ] };
}
export function populateRuntimeObject(object: ObjectInstance, world: World): RuntimeObjectInstance {
const objectType = world.objectTypes[object.typeId];
return {
...object,
cache: {
voxelGroups: getObjectVoxelGroups(objectType, world.voxelTypes),
colliderMesh: buildObjectTrimesh(objectType, world.voxelTypes),
boundingBox: computeBoundingBox(objectType),
updatedAt: 0,
},
pendingActions: {},
};
}
export function depopulateRuntimeObject(object: RuntimeObjectInstance, _world: World): ObjectInstance {
const { cache: _cache, ...result } = object;
return result;
}
+32
View File
@@ -0,0 +1,32 @@
import type { ObjectType, RuntimeGameObjectInstance, RuntimeGameScene, V3, World } from "../../types";
export class ObjectApi {
private object: RuntimeGameObjectInstance;
private obectType: ObjectType;
private world: World;
private scene: RuntimeGameScene;
constructor(
object: RuntimeGameObjectInstance,
obectType: ObjectType,
world: World,
scene: RuntimeGameScene,
) {
this.object = object;
this.obectType = obectType;
this.world = world;
this.scene = scene;
}
public applyImpulse(targetPosition: V3, amplitude: number) {
const [px, py, pz] = this.object.position;
const dx = targetPosition[0] - px;
const dy = targetPosition[1] - py;
const dz = targetPosition[2] - pz;
const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (len < 1e-6)
return;
const direction: V3 = [dx / len, dy / len, dz / len];
this.object.pendingActions.impulse = { direction, amplitude };
}
}
+16
View File
@@ -0,0 +1,16 @@
import type { RuntimeScene, Scene, World } from "../../types";
import { depopulateRuntimeObject, populateRuntimeObject } from "./object";
export function populateRuntimeScene(scene: Scene, world: World): RuntimeScene {
return {
...scene,
objects: Object.fromEntries(Object.entries(scene.objects).map(([id, obj]) => [id, populateRuntimeObject(obj, world)])),
};
}
export function depopulateRuntimeScene(scene: RuntimeScene, world: World): Scene {
return {
...scene,
objects: Object.fromEntries(Object.entries(scene.objects).map(([id, obj]) => [id, depopulateRuntimeObject(obj, world)])),
};
}
+16
View File
@@ -0,0 +1,16 @@
import type { RuntimeWorld, World } from "../../types";
import { depopulateRuntimeScene, populateRuntimeScene } from "./scene";
export function populateRuntimeWorld(world: World): RuntimeWorld {
return {
...world,
initialScene: populateRuntimeScene(world.initialScene, world),
};
}
export function depopulateRuntimeWorld(world: RuntimeWorld): World {
return {
...world,
initialScene: depopulateRuntimeScene(world.initialScene, world),
};
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

Some files were not shown because too many files have changed in this diff Show More