Compare commits

...

21 Commits

Author SHA1 Message Date
cha0s
dd456743f8 fix: dialogues and entities on ECS change 2024-07-14 02:38:59 -05:00
cha0s
ad18da3b93 fix: monopolizers reset on ECS change 2024-07-14 02:28:32 -05:00
cha0s
12ce0ccbd5 fun: chat 2024-07-14 02:26:43 -05:00
cha0s
b1df45baa9 fix: logical expression short-circuiting 2024-07-13 17:37:37 -05:00
cha0s
387e36613f feat: monopolizers 2024-07-13 17:13:48 -05:00
cha0s
8fb37dd2ba fun: font 2024-07-13 17:08:03 -05:00
cha0s
59e3c71a1a refactor: dialogue styles 2024-07-13 16:56:47 -05:00
cha0s
a0f6ca9056 refactor: entity updates 2024-07-13 16:33:23 -05:00
cha0s
70edcc04de fun: darkness later 2024-07-13 03:04:16 -05:00
cha0s
170a874faf perf: memo 2024-07-13 03:03:10 -05:00
cha0s
4a0b42fbe5 fun: dialog++ 2024-07-13 03:02:55 -05:00
cha0s
84b1c49527 fix: deps 2024-07-13 02:35:29 -05:00
cha0s
1d61c3c14a perf: less change 2024-07-13 00:34:26 -05:00
cha0s
5521d28131 refactor: easy rebuild 2024-07-13 00:34:07 -05:00
cha0s
4256d8770f refactor: less noise 2024-07-13 00:33:50 -05:00
cha0s
03035ec8b6 refactor: toNet 2024-07-13 00:33:41 -05:00
cha0s
5365608f9f feat: break 2024-07-12 18:32:55 -05:00
cha0s
53f4d35717 refactor: for...of 2024-07-12 17:41:55 -05:00
cha0s
554ae074c5 fix: nested for...of 2024-07-12 17:34:57 -05:00
cha0s
b98cdb00a3 fix: nested array destructuring 2024-07-12 04:04:09 -05:00
cha0s
b679d15b5c feat: for...of 2024-07-12 03:58:54 -05:00
34 changed files with 3978 additions and 381 deletions

View File

@ -6,6 +6,7 @@ const YIELD_NONE = 0;
const YIELD_PROMISE = 1;
const YIELD_LOOP_UPDATE = 2;
const YIELD_RETURN = 3;
const YIELD_BREAK = 4;
export default class Sandbox {
@ -38,6 +39,7 @@ export default class Sandbox {
if (
'BlockStatement' === node.type
|| 'ForStatement' === node.type
|| 'ForOfStatement' === node.type
) {
switch (verb) {
case 'enter': {
@ -69,12 +71,22 @@ export default class Sandbox {
if (null === element) {
continue;
}
if ('Identifier' === element.type) {
scope.allocate(element.name, init[i]);
}
/* v8 ignore next 3 */
else {
throw new Error(`destructureArray(): Can't array destructure type ${element.type}`);
switch (element.type) {
case 'ArrayPattern': {
this.destructureArray(element, init[i]);
break;
}
case 'ObjectPattern': {
this.destructureObject(element, init[i]);
break;
}
case 'Identifier': {
scope.allocate(element.name, init[i]);
break;
}
/* v8 ignore next 2 */
default:
throw new Error(`destructureArray(): Can't array destructure type ${element.type}`);
}
}
return init;
@ -137,7 +149,6 @@ export default class Sandbox {
case 'ChainExpression':
case 'ObjectExpression':
case 'Identifier':
case 'LogicalExpression':
case 'MemberExpression':
case 'UnaryExpression':
case 'UpdateExpression': {
@ -162,6 +173,45 @@ export default class Sandbox {
/* v8 ignore next 2 */
break;
}
case 'LogicalExpression': {
const shouldVisitChild = (child) => (
isReplaying
? (!this.$$execution.stack[depth + 1] || this.$$execution.stack[depth + 1] === child)
: true
);
if (shouldVisitChild(node.left)) {
const left = this.executeSync(node.left, depth + 1);
if (left.yield) {
return left;
}
this.$$execution.deferred.set(node.left, left);
}
const left = this.$$execution.deferred.get(node.left);
this.$$execution.deferred.delete(node.left);
if ('||' === node.operator && left.value) {
result = {
value: true,
yield: YIELD_NONE,
};
break;
}
if ('&&' === node.operator && !left.value) {
result = {
value: false,
yield: YIELD_NONE,
};
break;
}
const right = this.executeSync(node.right, depth + 1);
if (right.yield) {
return right;
}
result = {
value: !!right.value,
yield: YIELD_NONE,
};
break;
}
case 'BlockStatement': {
result = {
value: undefined,
@ -183,6 +233,24 @@ export default class Sandbox {
}
break;
}
case 'BreakStatement': {
walkUp: while (this.$$execution.stack.length > 0) {
const frame = this.$$execution.stack[this.$$execution.stack.length - 1];
switch (frame.type) {
case 'ForStatement': {
break walkUp;
}
case 'ForOfStatement': {
break walkUp;
}
default: this.$$execution.stack.pop();
}
}
return {
value: undefined,
yield: YIELD_BREAK,
};
}
case 'ConditionalExpression': {
const shouldVisitChild = (child) => (
isReplaying
@ -281,6 +349,10 @@ export default class Sandbox {
if (shouldVisitChild(node.body)) {
const body = this.executeSync(node.body, depth + 1);
if (body.yield) {
if (YIELD_BREAK === body.yield) {
this.$$execution.deferred.delete(node.body);
break;
}
return body;
}
this.$$execution.deferred.set(node.body, body);
@ -301,6 +373,87 @@ export default class Sandbox {
yield: YIELD_LOOP_UPDATE,
};
}
case 'ForOfStatement': {
const shouldVisitChild = (child) => (
isReplaying
? (!this.$$execution.stack[depth + 1] || this.$$execution.stack[depth + 1] === child)
: true
);
const scope = this.scopes.get(node);
if (shouldVisitChild(node.right)) {
const right = this.executeSync(node.right, depth + 1);
if (right.yield) {
return right;
}
scope.allocate('@@iterator', right.value[Symbol.iterator]());
}
result = this.$$execution.deferred.get(node.body) || {
value: undefined,
yield: YIELD_NONE,
};
if (shouldVisitChild(node)) {
this.$$execution.deferred.set(node.left, scope.get('@@iterator').next());
}
if (this.$$execution.stack[depth + 1] === node) {
this.$$execution.stack.pop();
}
const {done, value} = this.$$execution.deferred.get(node.left);
if (done) {
this.$$execution.deferred.delete(node.left);
this.$$execution.deferred.delete(node.body);
break;
}
switch (node.left.type) {
case 'ArrayPattern': {
this.destructureArray(node.left, value)
break;
}
case 'ObjectPattern': {
this.destructureObject(node.left, value)
break;
}
case 'VariableDeclaration': {
const [declaration] = node.left.declarations;
switch (declaration.id.type) {
case 'Identifier': {
scope.set(declaration.id.name, value);
break;
}
case 'ArrayPattern': {
this.destructureArray(declaration.id, value)
break;
}
case 'ObjectPattern': {
this.destructureObject(declaration.id, value)
break;
}
}
break;
}
case 'Identifier': {
scope.set(node.left.name, value);
break;
}
}
if (shouldVisitChild(node.body)) {
const body = this.executeSync(node.body, depth + 1);
if (body.yield) {
if (YIELD_BREAK === body.yield) {
this.$$execution.deferred.delete(node.left);
this.$$execution.deferred.delete(node.body);
break;
}
return body;
}
this.$$execution.deferred.set(node.body, body);
}
// Yield
this.$$execution.stack.push(node);
return {
value: undefined,
yield: YIELD_LOOP_UPDATE,
};
}
case 'IfStatement': {
const shouldVisitChild = (child) => (
isReplaying

View File

@ -84,6 +84,7 @@ test('destructures variables', async () => {
const {x: x1, y, z: {zz}} = {x: 4, y: 5, z: {zz: 6}};
const [d, e] = await [7, 8];
const {t, u: {uu}} = {t: 9, u: {uu: await 10}};
const [[v], {w}] = [[11], {w: 12}];
`),
);
await finish(sandbox);
@ -98,6 +99,8 @@ test('destructures variables', async () => {
e: 8,
t: 9,
uu: 10,
v: 11,
w: 12,
});
});
@ -436,7 +439,6 @@ test('handles nested yields', async () => {
test('handles optional expressions', async () => {
const context = {
console,
projected: undefined,
}
const sandbox = new Sandbox(
@ -449,3 +451,225 @@ test('handles optional expressions', async () => {
expect(sandbox.run())
.to.deep.include({value: undefined});
});
test('implements for...of', async () => {
const context = {
iterable: [1, 2, 3],
}
expect(
(new Sandbox(
await parse(`
const mapped = [];
for (const i of iterable) {
mapped.push(i * 2);
}
mapped
`),
context,
)).run()
)
.to.deep.include({value: [2, 4, 6]});
expect(
(new Sandbox(
await parse(`
const mapped = [];
for (j of iterable) {
mapped.push(j * 3);
}
mapped
`),
context,
)).run()
)
.to.deep.include({value: [3, 6, 9]});
context.iterable = [[1, 2], [3, 4], [5, 6]];
expect(
(new Sandbox(
await parse(`
const mapped = [];
for ([x, y] of iterable) {
mapped.push(x * y);
}
mapped
`),
context,
)).run()
)
.to.deep.include({value: [2, 12, 30]});
expect(
(new Sandbox(
await parse(`
const mapped = [];
for (const [u, v] of iterable) {
mapped.push(u * v);
}
mapped
`),
context,
)).run()
)
.to.deep.include({value: [2, 12, 30]});
context.iterable = [{x: 1, y: 2}, {x: 3, y: 4}, {x: 5, y: 6}];
expect(
(new Sandbox(
await parse(`
const mapped = [];
for ({x, y} of iterable) {
mapped.push(x * y);
}
mapped
`),
context,
)).run()
)
.to.deep.include({value: [2, 12, 30]});
expect(
(new Sandbox(
await parse(`
const mapped = [];
for (const {x, y} of iterable) {
mapped.push(x * y);
}
mapped
`),
context,
)).run()
)
.to.deep.include({value: [2, 12, 30]});
context.iterable = [{x: [[1, 2], [3, 4], [5, 6]]}];
expect(
(new Sandbox(
await parse(`
const mapped = [];
for (const {x} of iterable) {
for (const [y, z] of x) {
mapped.push(y * z);
}
}
mapped
`),
context,
)).run()
)
.to.deep.include({value: [2, 12, 30]});
});
test('breaks loops', async () => {
expect(
(new Sandbox(
await parse(`
const out = [];
for (let i = 0; i < 3; ++i) {
out.push(i);
break;
}
out
`),
)).run()
)
.to.deep.include({value: [0]});
expect(
(new Sandbox(
await parse(`
const out = [];
for (let i = 0; i < 3; ++i) {
out.push(i);
if (i > 0) {
break;
}
}
out
`),
)).run()
)
.to.deep.include({value: [0, 1]});
expect(
(new Sandbox(
await parse(`
const out = [];
for (const x of [1, 2, 3]) {
out.push(x);
break;
}
out
`),
)).run()
)
.to.deep.include({value: [1]});
expect(
(new Sandbox(
await parse(`
const out = [];
for (const x of [1, 2, 3]) {
for (const y of [4, 5, 6]) {
out.push(x);
if (y > 4) {
break;
}
}
}
out
`),
)).run()
)
.to.deep.include({value: [1, 1, 2, 2, 3, 3]});
expect(
(new Sandbox(
await parse(`
const out = [];
for (let x = 1; x < 4; ++x) {
for (let y = 4; y < 7; ++y) {
out.push(x);
if (y > 4) {
break;
}
}
}
out
`),
)).run()
)
.to.deep.include({value: [1, 1, 2, 2, 3, 3]});
});
test('short-circuits logical expressions', async () => {
let x = 0;
expect(
(new Sandbox(
await parse(`
let y = 0;
if (test || test()) {
y = 1;
}
y
`),
{
test: () => {
x = 1;
},
}
)).run()
)
.to.deep.include({value: 1});
expect(x)
.to.equal(0);
expect(
(new Sandbox(
await parse(`
let y = 0;
if (!test && test()) {
y = 1;
}
y
`),
{
test: () => {
x = 1;
},
}
)).run()
)
.to.deep.include({value: 0});
expect(x)
.to.equal(0);
});

View File

@ -5,11 +5,13 @@ export const TRAVERSAL_PATH = {
AwaitExpression: ['argument'],
BinaryExpression: ['left', 'right'],
BlockStatement: ['body'],
BreakStatement: [],
CallExpression: ['arguments', 'callee'],
ChainExpression: ['expression'],
ConditionalExpression: ['alternate', 'consequent', 'test'],
DoWhileStatement: ['body', 'test'],
ExpressionStatement: ['expression'],
ForOfStatement: ['body', 'left', 'right'],
ForStatement: ['body', 'init', 'test', 'update'],
Identifier: [],
IfStatement: ['alternate', 'consequent', 'test'],

View File

@ -77,7 +77,8 @@ export default async function createHomestead(id) {
interacting: 1,
interactScript: `
subject.Interlocutor.dialogue({
body: 'Hey what is up :)',
body: "Sure, I'm a treasure chest. Probably. Do you really think that means you're about to get some treasure? Hah!",
monopolizer: true,
origin: subject.Position.toJSON(),
position: {x: subject.Position.x, y: subject.Position.y - 32},
})

View File

@ -19,6 +19,7 @@ export default async function createPlayer(id) {
Emitter: {},
Forces: {},
Interacts: {},
Interlocutor: {},
Inventory: {
slots: {
1: {

56
app/dialogue.js Normal file
View File

@ -0,0 +1,56 @@
import mdx from 'remark-mdx';
import parse from 'remark-parse';
import {unified} from 'unified';
import {visitParents as visit} from 'unist-util-visit-parents';
const parser = unified().use(parse).use(mdx);
function computeParams(ancestors) {
const params = {};
for (let i = ancestors.length - 1; i >= 0; --i) {
const {dialogue} = ancestors[i];
if (dialogue) {
if (!(dialogue.name in params)) {
params[dialogue.name] = dialogue.params;
}
}
}
return params;
}
export function parseLetters(source) {
const tree = parser.parse(source);
tree.dialogue = {
name: 'rate',
params: {frequency: 0.05, length: 0},
};
const letters = [];
visit(tree, (node, ancestors) => {
switch (node.type) {
case 'mdxJsxFlowElement':
case 'mdxJsxTextElement': {
node.dialogue = {name: node.name, params: {length: 0}};
for (const {name, value: {value}} of node.attributes) {
node.dialogue.params[name] = value;
}
break;
}
case 'text': {
const params = computeParams(ancestors);
const split = node.value.split('');
for (let i = 0; i < split.length; ++i) {
const indices = {};
for (const name in params) {
indices[name] = i + params[name].length;
}
letters.push({character: split[i], indices, params});
}
for (const name in params) {
params[name].length += split.length;
}
break;
}
}
});
return letters;
}

View File

@ -16,6 +16,11 @@ export default class Interlocutor extends Component {
return class InterlocutorInstance extends super.instanceFromSchema() {
dialogues = {};
id = 0;
destroy() {
for (const key in this.dialogues) {
this.dialogues[key].onClose();
}
}
dialogue(specification) {
Component.markChange(this.entity, 'dialogue', {[this.id++]: specification});
}

View File

@ -12,7 +12,7 @@ export default class Interactions extends System {
tick() {
for (const entity of this.select('default')) {
const {Interacts} = entity;
Interacts.willInteractWith = 0
let willInteract = false;
const entities = Array.from(this.ecs.system('Colliders').within(Interacts.aabb()))
.filter((other) => other !== entity)
.sort(({Position: l}, {Position: r}) => {
@ -24,8 +24,12 @@ export default class Interactions extends System {
}
if (other.Interactive && other.Interactive.interacting) {
Interacts.willInteractWith = other.id;
willInteract = true;
}
}
if (!willInteract) {
Interacts.willInteractWith = 0;
}
}
}

View File

@ -149,9 +149,12 @@ export default class Component {
}
}
destroy() {}
toJSON() {
toNet() {
return Component.constructor.filterDefaults(this);
}
toJSON() {
return this.toNet();
}
};
const properties = {};
properties.entity = {

View File

@ -309,15 +309,21 @@ export default class Ecs {
}
rebuild(entityId, componentNames) {
let existing = [];
if (this.$$entities[entityId]) {
existing.push(...this.$$entities[entityId].constructor.componentNames);
let Class;
if (componentNames) {
let existing = [];
if (this.$$entities[entityId]) {
existing.push(...this.$$entities[entityId].constructor.componentNames);
}
Class = this.$$entityFactory.makeClass(componentNames(existing), this.Components);
}
else {
Class = this.$$entities[entityId].constructor;
}
const Class = this.$$entityFactory.makeClass(componentNames(existing), this.Components);
if (this.$$entities[entityId] && Class === this.$$entities[entityId].constructor) {
// Eventually - memoizable.
}
this.$$entities[entityId] = new Class(entityId);
return this.$$entities[entityId] = new Class(entityId);
}
reindex(entityIds) {

View File

@ -48,6 +48,11 @@ export default class EntityFactory {
${sorted.map((type) => `${type}: this.${type}.toJSON()`).join(', ')}
};
`);
Entity.prototype.toNet = new Function('', `
return {
${sorted.map((type) => `${type}: this.${type}.toNet()`).join(', ')}
};
`);
walk.class = Entity;
}
return walk.class;

View File

@ -156,9 +156,26 @@ export default class Engine {
if (!entity) {
continue;
}
const {Controlled, Ecs, Interacts, Inventory, Wielder} = entity;
const {
Controlled,
Ecs,
Interacts,
Interlocutor,
Inventory,
Position,
Wielder,
} = entity;
for (const payload of payloads) {
switch (payload.type) {
case 'chat': {
Interlocutor.dialogue({
body: payload.value,
linger: 5,
origin: Position.toJSON(),
position: {x: Position.x, y: Position.y - 32},
});
break;
}
case 'paint': {
const ecs = this.ecses[Ecs.path];
const {TileLayers} = ecs.get(1);
@ -389,7 +406,7 @@ export default class Engine {
const {id} = entity;
lastNearby.delete(id);
if (!memory.nearby.has(id)) {
update[id] = entity.toJSON();
update[id] = entity.toNet();
if (mainEntityId === id) {
update[id].MainEntity = {};
}

View File

@ -0,0 +1,91 @@
import {useEffect, useState} from 'react';
import {useClient} from '@/context/client.js';
import styles from './chat.module.css';
export default function Chat({
chatHistory,
onClose,
message,
setChatHistory,
setMessage,
}) {
const client = useClient();
const [disabled, setDisabled] = useState(true);
const [historyCaret, setHistoryCaret] = useState(0);
useEffect(() => {
setDisabled(false);
}, []);
return (
<div className={styles.chat}>
<form
onSubmit={(event) => {
if (message) {
client.send({
type: 'Action',
payload: {type: 'chat', value: message},
});
setChatHistory([message, ...chatHistory]);
setMessage('');
onClose();
}
event.preventDefault();
}}
>
<input
disabled={disabled}
onChange={(event) => {
setMessage(event.target.value);
}}
onKeyDown={(event) => {
switch (event.key) {
case 'ArrowDown': {
if (0 === historyCaret) {
break;
}
let localHistoryCaret = historyCaret - 1;
setMessage(chatHistory[localHistoryCaret])
setHistoryCaret(localHistoryCaret);
if (0 === localHistoryCaret) {
setChatHistory(chatHistory.slice(1));
}
break;
}
case 'ArrowUp': {
if (historyCaret === chatHistory.length - 1) {
break;
}
let localHistoryCaret = historyCaret;
let localChatHistory = chatHistory;
if (0 === historyCaret) {
localChatHistory = [message, ...localChatHistory];
setChatHistory(localChatHistory);
}
localHistoryCaret += 1;
setMessage(localChatHistory[localHistoryCaret])
setHistoryCaret(localHistoryCaret);
break;
}
case 'Escape': {
onClose();
break;
}
}
}}
onMouseDown={(event) => {
event.stopPropagation();
}}
maxLength="255"
ref={(element) => {
if (element) {
element.focus();
}
}}
type="text"
value={message}
/>
</form>
</div>
);
}

View File

@ -0,0 +1,22 @@
.chat {
background-color: #00000044;
position: absolute;
bottom: 0;
width: 100%;
}
.chat form {
line-height: 0;
input[type="text"] {
background-color: #00000044;
border: 1px solid #333333;
color: #ffffff;
margin: 4px;
width: calc(100% - 8px);
&:focus-visible {
border: 1px solid #999999;
outline: none;
}
}
}

View File

@ -2,6 +2,7 @@ import {useEffect, useRef, useState} from 'react';
import {RESOLUTION} from '@/constants.js';
import {useDomScale} from '@/context/dom-scale.js';
import {TAU} from '@/util/math.js';
import styles from './dialogue.module.css';
@ -10,21 +11,69 @@ const CARET_SIZE = 12;
export default function Dialogue({
camera,
dialogue,
onClose,
scale,
}) {
const domScale = useDomScale();
const ref = useRef();
const [dimensions, setDimensions] = useState({h: 0, w: 0});
const [caret, setCaret] = useState(0);
const [radians, setRadians] = useState(0);
useEffect(() => {
const {ttl = 5} = dialogue;
if (!ttl) {
return;
return dialogue.addSkipListener(() => {
if (caret >= dialogue.letters.length - 1) {
dialogue.onClose();
}
else {
setCaret(dialogue.letters.length - 1);
}
});
}, [caret, dialogue]);
useEffect(() => {
setRadians(0);
let handle;
let last;
const spin = (ts) => {
if ('undefined' === typeof last) {
last = ts;
}
const elapsed = (ts - last) / 1000;
last = ts;
setRadians((radians) => radians + (elapsed * TAU));
handle = requestAnimationFrame(spin);
};
handle = requestAnimationFrame(spin);
return () => {
cancelAnimationFrame(handle);
};
}, []);
useEffect(() => {
const {params} = dialogue.letters[caret];
let handle;
if (caret >= dialogue.letters.length - 1) {
const {linger} = dialogue;
if (!linger) {
return;
}
handle = setTimeout(() => {
dialogue.onClose();
}, linger * 1000);
}
setTimeout(() => {
onClose();
}, ttl * 1000);
}, [dialogue, onClose]);
else {
let jump = caret;
while (0 === dialogue.letters[jump].params.rate.frequency && jump < dialogue.letters.length - 1) {
jump += 1;
}
setCaret(jump);
if (jump < dialogue.letters.length - 1) {
handle = setTimeout(() => {
setCaret(caret + 1);
}, params.rate.frequency * 1000)
}
}
return () => {
clearTimeout(handle);
}
}, [caret, dialogue]);
useEffect(() => {
let handle;
function track() {
@ -101,20 +150,11 @@ export default function Dialogue({
caretPosition.y += Math.cos(caretRotation) * (CARET_SIZE / 2);
return (
<div
className={styles.dialogue}
ref={ref}
style={{
backgroundColor: '#00000044',
border: 'solid 1px white',
borderRadius: '8px',
color: 'white',
padding: '1em',
position: 'absolute',
left: `${left}px`,
margin: '0',
top: `${top}px`,
transform: 'translate(-50%, -50%)',
userSelect: 'none',
whiteSpace: 'nowrap',
}}
>
<svg
@ -134,7 +174,64 @@ export default function Dialogue({
>
<polygon points="0 0, 24 0, 12 24" />
</svg>
{dialogue.body}
<p className={styles.letters}>
{
dialogue.letters
.map(({character, indices, params}, i) => {
let color = 'inherit';
let fade = 0;
let fontStyle = 'normal';
let fontWeight = 'normal';
let left = 0;
let opacity = 1;
let top = 0;
if (params.blink) {
const {frequency = 1} = params.blink;
opacity = (radians * (2 / frequency) % TAU) > (TAU / 2) ? opacity : 0;
}
if (params.fade) {
const {frequency = 1} = params.fade;
fade = frequency;
}
if (params.wave) {
const {frequency = 1, magnitude = 3} = params.wave;
top += magnitude * Math.cos((radians * (1 / frequency)) + TAU * indices.wave / params.wave.length);
}
if (params.rainbow) {
const {frequency = 1} = params.rainbow;
color = `hsl(${(radians * (1 / frequency)) + TAU * indices.rainbow / params.rainbow.length}rad 100 50)`;
}
if (params.shake) {
const {magnitude = 1} = params.shake;
left += (Math.random() * magnitude * 2) - magnitude;
top += (Math.random() * magnitude * 2) - magnitude;
}
if (params.em) {
fontStyle = 'italic';
}
if (params.strong) {
fontWeight = 'bold';
}
return (
<span
className={styles.letter}
key={i}
style={{
color,
fontStyle,
fontWeight,
left: `${left}px`,
opacity: i <= caret ? opacity : 0,
top: `${top}px`,
transition: `opacity ${fade}s`,
}}
>
{character}
</span>
);
})
}
</p>
</div>
);
}

View File

@ -5,3 +5,26 @@
left: 50%;
top: 50%;
}
.dialogue {
background-color: #00000044;
border: solid 1px white;
border-radius: 8px;
color: white;
padding: 1em;
position: fixed;
margin: 0;
margin-right: -33%;
transform: translate(-50%, -50%);
user-select: none;
max-width: 33%;
}
.letters {
margin: 0;
}
.letter {
opacity: 0;
position: relative;
}

View File

@ -10,9 +10,6 @@ export default function Dialogues({camera, dialogues, scale}) {
camera={camera}
dialogue={dialogues[key]}
key={key}
onClose={() => {
delete dialogues[key];
}}
scale={scale}
/>
);

View File

@ -1,4 +1,9 @@
.dialogues {
font-family: 'Times New Roman', Times, serif;
font-family: Cookbook, Georgia, 'Times New Roman', Times, serif;
font-size: 16px;
}
@font-face {
font-family: "Cookbook";
src: url("/assets/fonts/Cookbook.woff") format("woff");
}

View File

@ -1,35 +1,86 @@
import {useState} from 'react';
import {usePacket} from '@/context/client.js';
import {useEcs, useEcsTick} from '@/context/ecs.js';
import {parseLetters} from '@/dialogue.js';
import Entity from './entity.jsx';
export default function Entities({camera, scale}) {
export default function Entities({camera, scale, setMonopolizers}) {
const [ecs] = useEcs();
const [entities, setEntities] = useState({});
usePacket('EcsChange', async () => {
setEntities({});
}, [setEntities]);
useEcsTick((payload) => {
if (!ecs) {
return;
}
const updatedEntities = {...entities};
const deleting = {};
const updating = {};
for (const id in payload) {
if ('1' === id) {
continue;
}
const update = payload[id];
if (false === update) {
delete updatedEntities[id];
deleting[id] = true;
continue;
}
updatedEntities[id] = ecs.get(id);
updating[id] = ecs.get(id);
const {dialogue} = update.Interlocutor || {};
if (dialogue) {
const {dialogues} = updating[id].Interlocutor;
for (const key in dialogue) {
updatedEntities[id].Interlocutor.dialogues[key] = dialogue[key];
dialogues[key] = dialogue[key];
dialogues[key].letters = parseLetters(dialogues[key].body);
const skipListeners = new Set();
dialogues[key].addSkipListener = (listener) => {
skipListeners.add(listener);
return () => {
skipListeners.delete(listener);
}
}
const monopolizer = {
trigger: () => {
for (const listener of skipListeners) {
listener();
}
},
};
if (dialogues[key].monopolizer) {
setMonopolizers((monopolizers) => [...monopolizers, monopolizer]);
}
dialogues[key].onClose = () => {
setEntities((entities) => ({
...entities,
[id]: ecs.rebuild(id),
}));
if (dialogues[key].monopolizer) {
setMonopolizers((monopolizers) => {
const index = monopolizers.indexOf(monopolizer);
if (-1 === index) {
return monopolizers;
}
monopolizers.splice(index, 1);
return [...monopolizers];
});
}
delete dialogues[key];
};
}
}
}
setEntities(updatedEntities);
}, [ecs, entities]);
setEntities((entities) => {
for (const id in deleting) {
delete entities[id];
}
return {
...entities,
...updating,
};
});
}, [ecs, setMonopolizers]);
const renderables = [];
for (const id in entities) {
renderables.push(

View File

@ -1,6 +1,8 @@
import {memo} from 'react';
import Dialogues from './dialogues.jsx';
export default function Entity({camera, entity, scale}) {
function Entity({camera, entity, scale}) {
return (
<>
{entity.Interlocutor && (
@ -12,4 +14,6 @@ export default function Entity({camera, entity, scale}) {
)}
</>
)
}
}
export default memo(Entity);

View File

@ -26,7 +26,7 @@ function calculateDarkness(hour) {
return Math.floor(darkness * 1000) / 1000;
}
export default function Ecs({applyFilters, camera, scale}) {
export default function Ecs({applyFilters, camera, monopolizers, scale}) {
const [ecs] = useEcs();
const [filters, setFilters] = useState([]);
const [mainEntity] = useMainEntity();
@ -89,15 +89,15 @@ export default function Ecs({applyFilters, camera, scale}) {
setProjected(Wielder.activeItem()?.project(Position.tile, Direction.direction));
}
}, [ecs, mainEntity, scale]);
useEffect(() => {
setFilters(
applyFilters
? [
...(night ? [night] : [])
]
: [],
);
}, [applyFilters, night])
// useEffect(() => {
// setFilters(
// applyFilters
// ? [
// ...(night ? [night] : [])
// ]
// : [],
// );
// }, [applyFilters, night])
return (
<Container
scale={scale}
@ -130,6 +130,7 @@ export default function Ecs({applyFilters, camera, scale}) {
)}
<Entities
filters={filters}
monopolizers={monopolizers}
/>
{projected?.length > 0 && layers[0] && (
<TargetingGhost

View File

@ -9,7 +9,7 @@ import {useMainEntity} from '@/context/main-entity.js';
import Entity from './entity.jsx';
export default function Entities({filters}) {
export default function Entities({filters, monopolizers}) {
const [ecs] = useEcs();
const [entities, setEntities] = useState({});
const [mainEntity] = useMainEntity();
@ -26,31 +26,44 @@ export default function Entities({filters}) {
if (!ecs) {
return;
}
const updatedEntities = {...entities};
const deleting = {};
const updating = {};
for (const id in payload) {
if ('1' === id) {
continue;
}
const update = payload[id];
if (false === update) {
delete updatedEntities[id];
deleting[id] = true;
continue;
}
else {
updatedEntities[id] = ecs.get(id);
if (update.Emitter?.emit) {
updatedEntities[id].Emitter.emitting = {
...updatedEntities[id].Emitter.emitting,
...update.Emitter.emit,
};
}
updating[id] = ecs.get(id);
if (update.Emitter?.emit) {
updating[id].Emitter.emitting = {
...updating[id].Emitter.emitting,
...update.Emitter.emit,
};
}
}
setEntities(updatedEntities);
setEntities((entities) => {
for (const id in deleting) {
delete entities[id];
}
return {
...entities,
...updating,
};
});
}, [ecs]);
useEcsTick(() => {
if (!ecs) {
return;
}
const main = ecs.get(mainEntity);
if (main) {
setWillInteractWith(main.Interacts.willInteractWith);
}
}, [ecs, entities, mainEntity]);
}, [ecs, mainEntity]);
useEffect(() => {
setRadians(0);
const handle = setInterval(() => {
@ -59,10 +72,10 @@ export default function Entities({filters}) {
return () => {
clearInterval(handle);
};
}, [willInteractWith]);
}, []);
const renderables = [];
for (const id in entities) {
const isHighlightedInteraction = id == willInteractWith;
const isHighlightedInteraction = 0 === monopolizers.length && id == willInteractWith;
renderables.push(
<Entity
filters={isHighlightedInteraction ? interactionFilters : null}

View File

@ -45,7 +45,7 @@ export const Stage = ({children, ...props}) => {
);
};
export default function Pixi({applyFilters, camera, scale}) {
export default function Pixi({applyFilters, camera, monopolizers, scale}) {
return (
<Stage
className={styles.stage}
@ -58,6 +58,7 @@ export default function Pixi({applyFilters, camera, scale}) {
<Ecs
applyFilters={applyFilters}
camera={camera}
monopolizers={monopolizers}
scale={scale}
/>
</Stage>

View File

@ -9,6 +9,7 @@ import {useEcs, useEcsTick} from '@/context/ecs.js';
import {useMainEntity} from '@/context/main-entity.js';
import Disconnected from './dom/disconnected.jsx';
import Chat from './dom/chat.jsx';
import Dom from './dom/dom.jsx';
import Entities from './dom/entities.jsx';
import HotBar from './dom/hotbar.jsx';
@ -64,6 +65,10 @@ export default function Ui({disconnected}) {
const [Components, setComponents] = useState();
const [Systems, setSystems] = useState();
const [applyFilters, setApplyFilters] = useState(true);
const [monopolizers, setMonopolizers] = useState([]);
const [message, setMessage] = useState('');
const [chatIsOpen, setChatIsOpen] = useState(false);
const [chatHistory, setChatHistory] = useState([]);
useEffect(() => {
async function setEcsStuff() {
const {default: Components} = await import('@/ecs-components/index.js');
@ -92,6 +97,9 @@ export default function Ui({disconnected}) {
}, [disconnected]);
useEffect(() => {
return addKeyListener(document.body, ({event, type, payload}) => {
if (chatIsOpen) {
return;
}
const KEY_MAP = {
keyDown: 1,
keyUp: 0,
@ -147,7 +155,19 @@ export default function Ui({disconnected}) {
actionPayload = {type: 'use', value: KEY_MAP[type]};
break;
}
case 'Enter': {
if ('keyDown' === type) {
setChatIsOpen(true);
}
break
}
case 'e': {
if (KEY_MAP[type]) {
if (monopolizers.length > 0) {
monopolizers[0].trigger();
break;
}
}
actionPayload = {type: 'interact', value: KEY_MAP[type]};
break;
}
@ -219,10 +239,11 @@ export default function Ui({disconnected}) {
});
}
});
}, [client, debug, devtoolsIsOpen, setDebug, setScale]);
}, [chatIsOpen, client, debug, devtoolsIsOpen, monopolizers, setDebug, setScale]);
usePacket('EcsChange', async () => {
setMainEntity(undefined);
setEcs(new ClientEcs({Components, Systems}));
setMainEntity(undefined);
setMonopolizers([]);
}, [Components, Systems, setEcs, setMainEntity]);
usePacket('Tick', async (payload, client) => {
if (0 === Object.keys(payload.ecs).length) {
@ -265,12 +286,13 @@ export default function Ui({disconnected}) {
}
if (localMainEntity) {
const mainEntityEntity = ecs.get(localMainEntity);
setCamera({
x: Math.round((mainEntityEntity.Camera.x * scale) - RESOLUTION.x / 2),
y: Math.round((mainEntityEntity.Camera.y * scale) - RESOLUTION.y / 2),
});
const x = Math.round((mainEntityEntity.Camera.x * scale) - RESOLUTION.x / 2);
const y = Math.round((mainEntityEntity.Camera.y * scale) - RESOLUTION.y / 2);
if (x !== camera.x || y !== camera.y) {
setCamera({x, y});
}
}
}, [ecs, mainEntity, scale]);
}, [camera, ecs, mainEntity, scale]);
useEffect(() => {
function onContextMenu(event) {
event.preventDefault();
@ -338,13 +360,16 @@ export default function Ui({disconnected}) {
}
break;
case 2:
if (monopolizers.length > 0) {
monopolizers[0].trigger();
break;
}
client.send({
type: 'Action',
payload: {type: 'interact', value: 1},
});
break;
}
event.preventDefault();
}}
onMouseUp={(event) => {
switch (event.button) {
@ -382,6 +407,7 @@ export default function Ui({disconnected}) {
<Pixi
applyFilters={applyFilters}
camera={camera}
monopolizers={monopolizers}
scale={scale}
/>
<Dom>
@ -398,7 +424,19 @@ export default function Ui({disconnected}) {
<Entities
camera={camera}
scale={scale}
setMonopolizers={setMonopolizers}
/>
{chatIsOpen && (
<Chat
chatHistory={chatHistory}
message={message}
setChatHistory={setChatHistory}
setMessage={setMessage}
onClose={() => {
setChatIsOpen(false);
}}
/>
)}
{showDisconnected && (
<Disconnected />
)}

3283
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -37,7 +37,11 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-tabs": "^6.0.2",
"remark-mdx": "^3.0.1",
"remark-parse": "^11.0.0",
"simplex-noise": "^4.0.1",
"unified": "^11.0.5",
"unist-util-visit-parents": "^6.0.1",
"ws": "^8.17.0"
},
"devDependencies": {

Binary file not shown.

View File

@ -3,12 +3,12 @@ const layer1 = ecs.get(1).TileLayers.layer(1)
const filtered = []
for (let i = 0; i < projected.length; ++i) {
for (const position of projected) {
if (
[1, 2, 3, 4, 6].includes(layer0.tile(projected[i]))
&& ![7].includes(layer1.tile(projected[i]))
[1, 2, 3, 4, 6].includes(layer0.tile(position))
&& ![7].includes(layer1.tile(position))
) {
filtered.push(projected[i])
filtered.push(position)
}
}

View File

@ -88,7 +88,7 @@ if (projected?.length > 0) {
for (let i = 0; i < 2; ++i) {
Sound.play('/assets/hoe/dig.wav');
for (let i = 0; i < projected.length; ++i) {
for (const {x, y} of projected) {
Emitter.emit({
...dirtParticles,
behaviors: [
@ -98,8 +98,8 @@ if (projected?.length > 0) {
config: {
type: 'rect',
data: {
x: projected[i].x * layer.tileSize.x,
y: projected[i].y * layer.tileSize.y,
x: x * layer.tileSize.x,
y: y * layer.tileSize.y,
w: layer.tileSize.x,
h: layer.tileSize.y,
}
@ -114,8 +114,8 @@ if (projected?.length > 0) {
await wait(0.1)
}
for (let i = 0; i < projected.length; ++i) {
TileLayers.layer(1).stamp(projected[i], [[7]])
for (const position of projected) {
TileLayers.layer(1).stamp(position, [[7]])
}
Controlled.locked = 0;

View File

@ -1,19 +1,16 @@
for (let i = 0; i < intersections.length; ++i) {
if (intersections[i][0].tags) {
if (intersections[i][0].tags.includes('door')) {
if (other.Player) {
ecs.switchEcs(
other,
entity.Ecs.path,
{
Position: {
x: 72,
y: 304,
},
for (const [{tags}] of intersections) {
if (tags && tags.includes('door')) {
if (other.Player) {
ecs.switchEcs(
other,
entity.Ecs.path,
{
Position: {
x: 72,
y: 304,
},
);
}
},
);
}
}
}

View File

@ -2,9 +2,9 @@ const layer = ecs.get(1).TileLayers.layer(1)
const filtered = []
for (let i = 0; i < projected.length; ++i) {
const x0 = projected[i].x * layer.tileSize.x;
const y0 = projected[i].y * layer.tileSize.y;
for (const position of projected) {
const x0 = position.x * layer.tileSize.x;
const y0 = position.y * layer.tileSize.y;
const entities = Array.from(ecs.system('Colliders').within({
x0,
x1: x0 + layer.tileSize.x - 1,
@ -12,15 +12,15 @@ for (let i = 0; i < projected.length; ++i) {
y1: y0 + layer.tileSize.y - 1,
}));
let hasPlant = false;
for (let j = 0; j < entities.length; ++j) {
if (entities[j].Plant) {
for (const {Plant} of entities) {
if (Plant) {
hasPlant = true
break;
}
}
if (!hasPlant) {
const tile = layer.tile(projected[i])
if ([7].includes(tile)) {
filtered.push(projected[i])
if ([7].includes(layer.tile(position))) {
filtered.push(position)
}
}
}

View File

@ -129,7 +129,7 @@ if (projected?.length > 0) {
Sprite.animation = ['idle', direction].join(':');
const promises = [];
for (let i = 0; i < projected.length; ++i) {
for (const {x, y} of projected) {
promises.push(ecs.create({
...plant,
Plant: {
@ -137,8 +137,8 @@ if (projected?.length > 0) {
growthFactor: Math.floor(Math.random() * 256),
},
Position: {
x: projected[i].x * layer.tileSize.x + (0.5 * layer.tileSize.x),
y: projected[i].y * layer.tileSize.y + (0.5 * layer.tileSize.y),
x: x * layer.tileSize.x + (0.5 * layer.tileSize.x),
y: y * layer.tileSize.y + (0.5 * layer.tileSize.y),
},
}))
}

View File

@ -2,10 +2,9 @@ const layer = ecs.get(1).TileLayers.layer(1)
const filtered = []
for (let i = 0; i < projected.length; ++i) {
const tile = layer.tile(projected[i])
if ([7].includes(tile)) {
filtered.push(projected[i])
for (const position of projected) {
if ([7].includes(layer.tile(position))) {
filtered.push(position)
}
}

View File

@ -80,7 +80,7 @@ if (projected?.length > 0) {
Sound.play('/assets/watering-can/water.wav');
for (let i = 0; i < 2; ++i) {
for (let i = 0; i < projected.length; ++i) {
for (const {x, y} of projected) {
Emitter.emit({
...waterParticles,
behaviors: [
@ -90,8 +90,8 @@ if (projected?.length > 0) {
config: {
type: 'rect',
data: {
x: projected[i].x * layer.tileSize.x,
y: projected[i].y * layer.tileSize.y - (layer.tileSize.y * 0.5),
x: x * layer.tileSize.x,
y: y * layer.tileSize.y - (layer.tileSize.y * 0.5),
w: layer.tileSize.x,
h: layer.tileSize.y,
}
@ -103,8 +103,8 @@ if (projected?.length > 0) {
await wait(0.25);
}
for (let i = 0; i < projected.length; ++i) {
const tileIndex = layer.area.x * projected[i].y + projected[i].x
for (const {x, y} of projected) {
const tileIndex = layer.area.x * y + x
let w;
if (Water.water[tileIndex]) {
w = Water.water[tileIndex]