workspaces folders
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 { };
|
||||
@@ -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 { };
|
||||
@@ -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 { };
|
||||
@@ -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 { };
|
||||
@@ -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);
|
||||
@@ -0,0 +1,3 @@
|
||||
export function startGame() {
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './events';
|
||||
export * from './values';
|
||||
export * from './actions';
|
||||
export * from './theme';
|
||||
export * from './generateCode';
|
||||
export * from './fieldTypes';
|
||||
@@ -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 { };
|
||||
@@ -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 { };
|
||||
@@ -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 { };
|
||||
@@ -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 { };
|
||||
@@ -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}
|
||||
/>;
|
||||
});
|
||||
@@ -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>
|
||||
</>);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
});
|
||||
@@ -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>;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 >;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>);
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export const chartRef = { current: document.createElement('span') };
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
@@ -0,0 +1,3 @@
|
||||
export class Scene {
|
||||
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './rootState';
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './3d';
|
||||
export * from './model';
|
||||
export * from './runtime';
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { RuntimeGameScene } from "./scene";
|
||||
|
||||
export type Game = {
|
||||
paused: boolean;
|
||||
time: number;
|
||||
scene: RuntimeGameScene;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type GameRules = {
|
||||
gravity: boolean;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './world';
|
||||
export * from './scene';
|
||||
export * from './object';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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)])),
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user