Styling improvment
All checks were successful
Build and publish / Build Container (push) Successful in 1m41s

This commit is contained in:
2026-03-02 02:49:15 -05:00
parent c5c070ebc2
commit 8c2b80951b
15 changed files with 824 additions and 823 deletions

View File

@@ -11,4 +11,5 @@ RUN apt-get update && apt-get install -y --no-install-recommends libpython3.11 p
echo 'alias pip="pip3"' >> /root/.bashrc && \ echo 'alias pip="pip3"' >> /root/.bashrc && \
echo 'alias python="python3"' >> /root/.bashrc echo 'alias python="python3"' >> /root/.bashrc
WORKDIR /tmp
CMD ["npm", "run", "start"] CMD ["npm", "run", "start"]

7
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@ztimson/ai-utils": "^0.8.7", "@ztimson/ai-utils": "^0.8.7",
"@ztimson/utils": "^0.28.14",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
"socket.io": "^4.6.1" "socket.io": "^4.6.1"
@@ -324,9 +325,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@ztimson/utils": { "node_modules/@ztimson/utils": {
"version": "0.28.13", "version": "0.28.14",
"resolved": "https://registry.npmjs.org/@ztimson/utils/-/utils-0.28.13.tgz", "resolved": "https://registry.npmjs.org/@ztimson/utils/-/utils-0.28.14.tgz",
"integrity": "sha512-6nk7mW1vPX5QltTjCNK6u6y8UGYTOgeDadWHbrD+1QM5PZ1PbosMqVMQ6l4dhsQT7IHFxvLI3+U720T2fLGwoA==", "integrity": "sha512-ZI8kT1CV9La22w/E+HoPOsSV0ewgz0Rl4LXKIxFxH2dJVkXDCajgjfSHEfIHniifeOMma2nF+VO6yqT9BtvorQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"var-persist": "^1.0.1" "var-persist": "^1.0.1"

View File

@@ -4,6 +4,7 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@ztimson/ai-utils": "^0.8.7", "@ztimson/ai-utils": "^0.8.7",
"@ztimson/utils": "^0.28.14",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
"socket.io": "^4.6.1" "socket.io": "^4.6.1"

162
public/components/btn.mjs Normal file
View File

@@ -0,0 +1,162 @@
function contrast(color) {
const exploded = color?.match(color.length >= 6 ? /[0-9a-fA-F]{2}/g : /[0-9a-fA-F]/g);
if(!exploded || exploded?.length < 3) return 'black';
const [r, g, b] = exploded.map(hex => parseInt(hex.length === 1 ? `${hex}${hex}` : hex, 16));
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? 'black' : 'white';
}
function shadeColor(hex, amount) {
function dec2Hex(num) {
const hex = Math.round(num * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
}
function hex2Int(hex) {
let r = 0, g = 0, b = 0;
if (hex.length === 4) {
r = parseInt(hex[1] + hex[1], 16);
g = parseInt(hex[2] + hex[2], 16);
b = parseInt(hex[3] + hex[3], 16);
} else {
r = parseInt(hex.slice(1, 3), 16);
g = parseInt(hex.slice(3, 5), 16);
b = parseInt(hex.slice(5, 7), 16);
}
return { r, g, b };
}
function hue2rgb(p, q, t) {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
}
function int2Hex(r, g, b) {
return '#' + dec2Hex(r) + dec2Hex(g) + dec2Hex(b);
}
let { r, g, b } = hex2Int(hex);
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
default: h = 0; break;
}
h /= 6;
}
l = Math.max(0, Math.min(1, l + amount));
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return int2Hex(hue2rgb(p, q, h + 1 / 3), hue2rgb(p, q, h), hue2rgb(p, q, h - 1 / 3));
}
class BtnComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
--base-color: #ccc;
--dark-color: #999;
--light-color: #eee;
--text-color: #000;
}
.btn {
display: inline-flex;
min-width: 40px;
width: 100%;
height: 40px;
padding: 5px;
border-radius: 6px;
font-size: 20px;
transition: transform 0.1s, background-color 0.2s, border-color 0.2s, box-shadow 0.2s;
align-items: center;
justify-content: center;
user-select: none;
cursor: url('/assets/cursor.png'), auto;
fill: var(--text-color);
color: var(--text-color);
box-sizing: border-box;
}
.btn:not(.disabled) {
background: var(--base-color);
border: 2px solid var(--dark-color);
box-shadow: 0 4px 0 var(--dark-color);
}
.btn:not(.disabled):hover {
transform: translateY(-2px);
background: var(--light-color);
border: 2px solid var(--base-color);
box-shadow: 0 6px 0 var(--base-color);
}
.btn:not(.disabled):active {
transform: translateY(2px);
background: var(--base-color);
border: 2px solid var(--dark-color);
box-shadow: 0 2px 0 var(--dark-color);
}
.btn.disabled {
cursor: no-drop;
background: var(--dark-color);
border: 2px solid var(--base-color);
box-shadow: 0 4px 0 var(--dark-color);
}
</style>
<div class="btn">
<slot></slot>
</div>
`;
this.shadowRoot.querySelector('.btn').addEventListener('click', (e) => {
if(this.hasAttribute('disabled')) {
e.stopPropagation();
e.preventDefault();
}
});
this.updateColors();
}
static get observedAttributes() {
return ['color', 'disabled'];
}
attributeChangedCallback(name, oldValue, newValue) {
if(name === 'color') this.updateColors();
if(name === 'disabled') {
const disabled = this.hasAttribute('disabled');
this.shadowRoot.querySelector('.btn').classList[disabled ? 'add' : 'remove']('disabled');
}
}
updateColors() {
const hex = this.getAttribute('color');
this.shadowRoot.host.style.setProperty('--base-color', hex);
this.shadowRoot.host.style.setProperty('--dark-color', shadeColor(hex, -.1));
this.shadowRoot.host.style.setProperty('--light-color', shadeColor(hex, .1));
this.shadowRoot.host.style.setProperty('--text-color', contrast(hex));
}
}
customElements.define('btn-component', BtnComponent);

View File

@@ -1,35 +1,28 @@
import './btn.mjs';
class JukeboxComponent extends HTMLElement { class JukeboxComponent extends HTMLElement {
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: 'open' }); this.attachShadow({ mode: 'open' });
this.navi = window.navi;
// Use global singleton 🎵 this.navi.init();
this.api = window.netNaviAPI; this.unsubscribeWorld = this.navi.on('world:data', (data) => {
console.log(data, this.navi.world.data);
this.render()
});
this.playlist = []; this.playlist = [];
this.currentTrackIndex = 0; this.currentTrackIndex = 0;
this.bgMusic = null; this.bgMusic = null;
this.isMuted = false; this.isMuted = false;
this.hasInteracted = false; this.hasInteracted = false;
this.theme = null;
this.isPlaylistMode = false; this.isPlaylistMode = false;
} }
connectedCallback() {
this.render();
this.setupAPIListeners();
}
disconnectedCallback() { disconnectedCallback() {
if(this.unsubscribeWorld) this.unsubscribeWorld(); if(this.unsubscribeWorld) this.unsubscribeWorld();
} }
setupAPIListeners() {
this.unsubscribeWorld = this.api.on('world:loaded', (data) => {
if(data.theme?.music) this.loadMusic(data.theme.music, data.theme);
});
}
render() { render() {
this.shadowRoot.innerHTML = ` this.shadowRoot.innerHTML = `
<style> <style>
@@ -44,8 +37,8 @@ class JukeboxComponent extends HTMLElement {
display: flex; display: flex;
top: 20px; top: 20px;
right: 20px; right: 20px;
background: var(--dialogue-header-bg, #ffffff); background: ${this.navi.world.data.theme.colors.primary};
border: 3px solid var(--dialogue-border, #000); border: 3px solid ${this.navi.world.data.theme.colors.border};
border-radius: 8px; border-radius: 8px;
padding: 12px; padding: 12px;
z-index: 1000; z-index: 1000;
@@ -55,8 +48,9 @@ class JukeboxComponent extends HTMLElement {
.track-info { .track-info {
display: flex; display: flex;
align-items: center; align-items: center;
background: var(--dialogue-bg, #8b5cf6); background: ${this.navi.world.data.theme.colors.background};
color: var(--dialogue-text, #ffffff); border: 2px solid ${this.navi.world.data.theme.colors.border};
color: ${this.navi.world.data.theme.colors.text};
padding: 8px; padding: 8px;
border-radius: 4px; border-radius: 4px;
margin: 0 8px; margin: 0 8px;
@@ -64,6 +58,7 @@ class JukeboxComponent extends HTMLElement {
height: 24px; height: 24px;
width: 120px; width: 120px;
position: relative; position: relative;
cursor: text;
} }
.track-name { .track-name {
white-space: nowrap; white-space: nowrap;
@@ -77,69 +72,37 @@ class JukeboxComponent extends HTMLElement {
0% { transform: translateX(0); } 0% { transform: translateX(0); }
100% { transform: translateX(-100%); } 100% { transform: translateX(-100%); }
} }
.controls-row { .hidden {
display: flex; display: none;
gap: 8px;
justify-content: center;
align-items: center;
} }
.control-btn {
width: 40px;
height: 40px;
background: var(--button-bg, #6366f1);
border: 2px solid var(--dialogue-border, #000);
border-radius: 6px;
font-size: 20px;
box-shadow: 0 3px 0 var(--button-shadow, #4338ca);
transition: transform 0.1s;
display: flex;
align-items: center;
justify-content: center;
}
.control-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 0 var(--button-shadow, #4338ca);
}
.control-btn:active {
transform: translateY(2px);
box-shadow: 0 1px 0 var(--button-shadow, #4338ca);
}
.control-btn svg {
width: 24px;
height: 24px;
fill: #fff;
}
.hidden { display: none; }
</style> </style>
<button class="control-btn" id="simple-mute-btn"> <btn-component id="simple-mute-btn" color="${this.navi.world.data.theme.colors.accent}">
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/> <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg> </svg>
</button> </btn-component>
<div class="audio-controls hidden" id="playlist-controls"> <div class="audio-controls hidden" id="playlist-controls">
<div class="controls-row"> <btn-component id="prev-btn" color="${this.navi.world.data.theme.colors.accent}">
<button class="control-btn" id="prev-btn"> <svg viewBox="0 0 24 24">
<svg viewBox="0 0 24 24"> <path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/> </svg>
</svg> </btn-component>
</button>
</div>
<div class="track-info"> <div class="track-info">
<span class="track-name" id="track-name">No track loaded</span> <span class="track-name" id="track-name">No track loaded</span>
</div> </div>
<div class="controls-row"> <div style="display: flex; gap: 8px">
<button class="control-btn" id="next-btn"> <btn-component id="next-btn" color="${this.navi.world.data.theme.colors.accent}">
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/> <path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
</svg> </svg>
</button> </btn-component>
<button class="control-btn" id="mute-btn"> <btn-component id="mute-btn" color="#c0392b">
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/> <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg> </svg>
</button> </btn-component>
</div> </div>
</div> </div>
`; `;
@@ -147,18 +110,15 @@ class JukeboxComponent extends HTMLElement {
this.shadowRoot.getElementById('mute-btn').addEventListener('click', () => this.toggleMute()); this.shadowRoot.getElementById('mute-btn').addEventListener('click', () => this.toggleMute());
this.shadowRoot.getElementById('prev-btn').addEventListener('click', () => this.previousTrack()); this.shadowRoot.getElementById('prev-btn').addEventListener('click', () => this.previousTrack());
this.shadowRoot.getElementById('next-btn').addEventListener('click', () => this.nextTrack()); this.shadowRoot.getElementById('next-btn').addEventListener('click', () => this.nextTrack());
this.loadMusic(this.navi.world.data.theme.music);
} }
loadMusic(musicConfig, theme) { loadMusic(musicConfig) {
if (!musicConfig) return; if(!musicConfig) return;
this.theme = theme;
this.isPlaylistMode = Array.isArray(musicConfig); this.isPlaylistMode = Array.isArray(musicConfig);
this.playlist = Array.isArray(musicConfig) ? musicConfig : [musicConfig]; this.playlist = Array.isArray(musicConfig) ? musicConfig : [musicConfig];
this.currentTrackIndex = 0; this.currentTrackIndex = 0;
this.applyThemeColors();
if (this.isPlaylistMode) { if (this.isPlaylistMode) {
this.shadowRoot.getElementById('simple-mute-btn').classList.add('hidden'); this.shadowRoot.getElementById('simple-mute-btn').classList.add('hidden');
this.shadowRoot.getElementById('playlist-controls').classList.remove('hidden'); this.shadowRoot.getElementById('playlist-controls').classList.remove('hidden');
@@ -205,15 +165,6 @@ class JukeboxComponent extends HTMLElement {
trackName.textContent = `[${trackNum}] ${fileName}`; trackName.textContent = `[${trackNum}] ${fileName}`;
} }
applyThemeColors() {
if (!this.theme) return;
const root = this.shadowRoot.host.style;
Object.entries(this.theme.colors).forEach(([key, value]) => {
const cssVar = '--' + key.replace(/([A-Z])/g, '-$1').toLowerCase();
root.setProperty(cssVar, value);
});
}
setupAutoplayHandler() { setupAutoplayHandler() {
const startMusic = () => { const startMusic = () => {
if (!this.hasInteracted && !this.isMuted && this.bgMusic) { if (!this.hasInteracted && !this.isMuted && this.bgMusic) {

View File

@@ -1,3 +1,5 @@
import './btn.mjs';
class LlmComponent extends HTMLElement { class LlmComponent extends HTMLElement {
hideTools = ['adjust_personality', 'recall', 'remember'] hideTools = ['adjust_personality', 'recall', 'remember']
@@ -6,11 +8,9 @@ class LlmComponent extends HTMLElement {
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: 'open' }); this.attachShadow({ mode: 'open' });
this.navi = window.navi;
this.navi.init().then(() => this.render());
// Use global singleton 🔥
this.api = window.netNaviAPI;
this.isTyping = false;
this.isReceiving = false; this.isReceiving = false;
this.streamComplete = false; this.streamComplete = false;
this.isDialogueOpen = false; this.isDialogueOpen = false;
@@ -23,9 +23,6 @@ class LlmComponent extends HTMLElement {
this.currentRequest = null; this.currentRequest = null;
this.attachedFiles = []; this.attachedFiles = [];
this.currentStreamingMessage = null; this.currentStreamingMessage = null;
this.render();
this.initEventListeners();
} }
render() { render() {
@@ -41,8 +38,8 @@ class LlmComponent extends HTMLElement {
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--dialogue-header-bg, #fff); background: ${this.navi.theme.primary};
border: 2px solid var(--dialog-border, #000); border: 2px solid ${this.navi.theme.border};
border-radius: 9px; border-radius: 9px;
} }
@@ -76,8 +73,8 @@ class LlmComponent extends HTMLElement {
} }
.dialogue-content { .dialogue-content {
background: var(--dialogue-bg, #fff); background: ${this.navi.theme.background};
border: 5px solid var(--dialogue-border, #000); border: 5px solid ${this.navi.theme.border};
border-radius: 16px 16px 0 0; border-radius: 16px 16px 0 0;
padding: 0; padding: 0;
box-shadow: 0 -4px 20px rgba(0,0,0,0.5); box-shadow: 0 -4px 20px rgba(0,0,0,0.5);
@@ -95,9 +92,9 @@ class LlmComponent extends HTMLElement {
.dialogue-header { .dialogue-header {
user-select: none; user-select: none;
padding: 12px 20px; padding: 10px 10px 10px 15px;
background: var(--dialogue-header-bg, #fff); background: ${this.navi.theme.primary};
border-bottom: 3px solid var(--dialogue-border, #000); border-bottom: 3px solid ${this.navi.theme.border};
border-radius: 12px 12px 0 0; border-radius: 12px 12px 0 0;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -109,47 +106,13 @@ class LlmComponent extends HTMLElement {
border-radius: 0; border-radius: 0;
} }
.speaker-name {
font-weight: bold;
font-size: 16px;
color: var(--dialogue-text, #000);
font-family: 'Courier New', monospace;
pointer-events: none;
}
.expand-btn {
background: var(--button-bg, #4a90e2);
border: 2px solid var(--dialogue-border, #000);
color: var(--button-text, #fff);
padding: 8px;
font-family: 'Courier New', monospace;
font-weight: bold;
font-size: 14px;
border-radius: 4px;
box-shadow: 0 3px 0 var(--button-shadow, #2a5a9a);
display: flex;
align-items: center;
gap: 4px;
}
.expand-btn:active {
transform: translateY(2px);
box-shadow: 0 1px 0 var(--button-shadow, #2a5a9a);
}
.expand-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.message-body { .message-body {
padding: 20px; padding: 1.75rem 1.25rem;
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 1.75rem;
min-height: 200px; min-height: 200px;
max-height: 400px; max-height: 400px;
} }
@@ -176,51 +139,41 @@ class LlmComponent extends HTMLElement {
.message-label { .message-label {
font-size: 12px; font-size: 12px;
font-weight: bold; font-weight: bold;
color: var(--dialogue-text, #000); color: ${this.navi.theme.text};
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
opacity: 0.7; opacity: 0.7;
padding: 0 8px; padding: 0 8px;
} }
.message-bubble { .message-bubble {
max-width: 80%;
padding: 12px 16px;
border-radius: 12px;
font-size: 15px; font-size: 15px;
line-height: 1.5; line-height: 1.5;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
word-wrap: break-word; word-wrap: break-word;
border: 2px solid var(--dialogue-border, #000);
box-shadow: 2px 2px 0 rgba(0,0,0,0.2);
user-select: text; user-select: text;
} }
.message-wrapper.user .message-bubble { .message-wrapper.user .message-bubble {
background: var(--button-bg, #4a90e2); background: ${this.navi.theme.primary};
color: var(--button-text, #fff); color: ${this.navi.theme.primaryContrast};
padding: 0.5rem 0.75rem;
border-radius: 12px;
border: 2px solid ${this.navi.theme.border};
} }
.message-wrapper.assistant .message-bubble { .message-wrapper.navi .message-bubble {
background: var(--dialogue-bg, #fff); color: ${this.navi.theme.text};
color: var(--dialogue-text, #000);
}
.message-timestamp {
font-size: 10px;
opacity: 0.6;
padding: 0 8px;
font-family: 'Courier New', monospace;
color: var(--dialogue-text, #000);
} }
.empty-state { .empty-state {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-align: center;;
height: 100%; height: 100%;
font-size: 32px; font-size: 32px;
font-weight: bold; font-weight: bold;
color: var(--dialogue-text, #000); color: ${this.navi.theme.text};
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
text-shadow: text-shadow:
3px 3px 0 rgba(74, 144, 226, 0.3), 3px 3px 0 rgba(74, 144, 226, 0.3),
@@ -262,7 +215,7 @@ class LlmComponent extends HTMLElement {
.tool-call { .tool-call {
display: inline-block; display: inline-block;
background: var(--button-bg, #000); background: ${this.navi.theme.primary};
color: #fff; color: #fff;
padding: 2px 8px; padding: 2px 8px;
margin: 2px; margin: 2px;
@@ -281,11 +234,11 @@ class LlmComponent extends HTMLElement {
.file-badge { .file-badge {
display: flex; display: flex;
align-items: center; align-items: center;
background: var(--button-bg, #4a90e2); background: ${this.navi.theme.primary};
color: var(--button-text, #fff); color: ${this.navi.theme.primaryContrast};
padding: 4px 10px; padding: 4px 10px;
margin: 0; margin: 0;
border: 2px solid var(--dialogue-border, #000); border: 2px solid ${this.navi.theme.border};
border-radius: 6px; border-radius: 6px;
font-size: 13px; font-size: 13px;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
@@ -304,14 +257,14 @@ class LlmComponent extends HTMLElement {
justify-content: flex-end; justify-content: flex-end;
} }
.message-wrapper.assistant .message-files { .message-wrapper.navi .message-files {
justify-content: flex-start; justify-content: flex-start;
} }
.attached-files { .attached-files {
padding: 8px 12px; padding: 8px 12px;
border-top: 3px solid var(--dialogue-border, #000); border-top: 3px solid ${this.navi.theme.border};
background: var(--dialogue-input-bg, #f0f0f0); background: ${this.navi.theme.backgroundDark};
display: none; display: none;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
@@ -325,8 +278,9 @@ class LlmComponent extends HTMLElement {
.attached-file { .attached-file {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
background: var(--dialogue-bg, #fff); background: ${this.navi.theme.primary};
border: 2px solid var(--dialogue-border, #000); color: ${this.navi.theme.primaryContrast};
border: 2px solid ${this.navi.theme.border};
padding: 6px 10px; padding: 6px 10px;
border-radius: 6px; border-radius: 6px;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
@@ -343,7 +297,7 @@ class LlmComponent extends HTMLElement {
.attached-file .remove-file { .attached-file .remove-file {
background: #e74c3c; background: #e74c3c;
border: none; border: 1px solid black;
color: #fff; color: #fff;
padding: 2px 6px; padding: 2px 6px;
border-radius: 3px; border-radius: 3px;
@@ -357,8 +311,8 @@ class LlmComponent extends HTMLElement {
.dialogue-input { .dialogue-input {
padding: 12px 20px; padding: 12px 20px;
border-top: 3px solid var(--dialogue-border, #000); border-top: 3px solid ${this.navi.theme.border};
background: var(--dialogue-input-bg, #f0f0f0); background: ${this.navi.theme.backgroundDark};
flex-shrink: 0; flex-shrink: 0;
} }
@@ -368,13 +322,13 @@ class LlmComponent extends HTMLElement {
.dialogue-input textarea { .dialogue-input textarea {
width: 100%; width: 100%;
background: var(--dialogue-bg, #fff); background: ${this.navi.theme.background};
border: 3px solid var(--dialogue-border, #000); border: 3px solid ${this.navi.theme.border};
padding: 10px 14px; padding: 10px 14px;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
font-size: 16px; font-size: 16px;
border-radius: 4px; border-radius: 4px;
color: var(--dialogue-text, #000); color: ${this.navi.theme.text};
resize: none; resize: none;
min-height: 44px; min-height: 44px;
max-height: 120px; max-height: 120px;
@@ -384,7 +338,6 @@ class LlmComponent extends HTMLElement {
.dialogue-input textarea:focus { .dialogue-input textarea:focus {
outline: none; outline: none;
box-shadow: 0 0 0 2px var(--button-bg, #4a90e2);
} }
.dialogue-input textarea:disabled { .dialogue-input textarea:disabled {
@@ -395,78 +348,57 @@ class LlmComponent extends HTMLElement {
.button-row { .button-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
padding-bottom: 5px;
} }
.clear-btn, .attach-btn, .dialogue-send-btn { .clear-btn, .attach-btn, .dialogue-send-btn {
background: var(--button-bg, #4a90e2); background: ${this.navi.theme.accent};
border: 3px solid var(--dialogue-border, #000); border: 3px solid ${this.navi.theme.border};
color: var(--button-text, #fff); color: ${this.navi.theme.accentContrast};
padding: 10px 18px; padding: 10px 18px;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
font-weight: bold; font-weight: bold;
font-size: 14px; font-size: 14px;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 3px 0 var(--button-shadow, #2a5a9a); box-shadow: 0 3px 0 ${this.navi.theme.accentDark};
transition: background 0.2s; transition: background 0.2s;
white-space: nowrap; white-space: nowrap;
flex: 1; flex: 1;
} }
.clear-btn {
background: #e74c3c;
box-shadow: 0 3px 0 #c0392b;
}
.clear-btn:active {
transform: translateY(2px);
box-shadow: 0 1px 0 #c0392b;
}
.attach-btn:active, .dialogue-send-btn:active {
transform: translateY(2px);
box-shadow: 0 1px 0 var(--button-shadow, #2a5a9a);
}
.attach-btn:disabled, .dialogue-send-btn:disabled, .clear-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dialogue-send-btn.stop { .dialogue-send-btn.stop {
background: #e74c3c; background: #e74c3c;
box-shadow: 0 3px 0 #c0392b; box-shadow: 0 3px 0 #;
} }
.dialogue-send-btn.stop:active { .dialogue-send-btn.stop:active {
box-shadow: 0 1px 0 #c0392b; box-shadow: 0 1px 0 #c0392b;
} }
.dialogue-send-btn.skip {
background: #f39c12;
box-shadow: 0 3px 0 #d68910;
}
.dialogue-send-btn.skip:active {
box-shadow: 0 1px 0 #d68910;
}
#file-input { #file-input {
display: none; display: none;
} }
.flex-fill-even {
flex: 1 1 0;
}
</style> </style>
<div id="dialogue-box" class="minimized"> <div id="dialogue-box" class="minimized">
<div class="dialogue-content"> <div class="dialogue-content">
<div class="dialogue-header" id="dialogue-header"> <div class="dialogue-header" id="dialogue-header">
<img alt="logo" src="/favicon.png" style="height: 32px; width: auto;"> <div style="display: flex; align-items: center; gap: 0.5rem">
<button class="expand-btn" id="expand-btn"> <img alt="logo" src="${this.navi.icon}" style="height: 32px; width: auto;">
<span style="color: ${this.navi.theme.primaryContrast}; font-size: 1.75rem">${this.navi.info.name}</span>
</div>
<btn-component id="expand-btn" color="${this.navi.theme.accent}">
<svg class="expand-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg class="expand-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="8" height="8" stroke="currentColor" stroke-width="2.5" fill="none"/> <rect x="3" y="3" width="8" height="8" stroke="currentColor" stroke-width="2.5" fill="none"/>
<rect x="13" y="13" width="8" height="8" stroke="currentColor" stroke-width="2.5" fill="none"/> <rect x="13" y="13" width="8" height="8" stroke="currentColor" stroke-width="2.5" fill="none"/>
<path d="M11 3 L11 11 L3 11" stroke="currentColor" stroke-width="2.5" fill="none"/> <path d="M11 3 L11 11 L3 11" stroke="currentColor" stroke-width="2.5" fill="none"/>
<path d="M13 21 L13 13 L21 13" stroke="currentColor" stroke-width="2.5" fill="none"/> <path d="M13 21 L13 13 L21 13" stroke="currentColor" stroke-width="2.5" fill="none"/>
</svg> </svg>
</button> </btn-component>
</div> </div>
<div class="message-body" id="message-body"> <div class="message-body" id="message-body">
<div class="empty-state">NetNavi v1.0.0</div> <div class="empty-state">NetNavi v1.0.0</div>
@@ -477,89 +409,84 @@ class LlmComponent extends HTMLElement {
<textarea id="dialogue-input" placeholder="Type your message..." rows="1"></textarea> <textarea id="dialogue-input" placeholder="Type your message..." rows="1"></textarea>
</div> </div>
<div class="button-row"> <div class="button-row">
<button class="clear-btn" id="clear-btn">CLEAR</button> <btn-component class="flex-fill-even" id="clear-btn" color="#c0392b">CLEAR</btn-component>
<button class="attach-btn" id="attach-btn">ATTACH</button> <btn-component class="flex-fill-even" id="attach-btn" color="${this.navi.theme.accent}">ATTACH</btn-component>
<button class="dialogue-send-btn" id="dialogue-send">SEND</button> <btn-component class="flex-fill-even" id="dialogue-send" color="${this.navi.theme.accent}">SEND</btn-component>
</div> </div>
</div> </div>
<input type="file" id="file-input" multiple accept="*/*"> <input type="file" id="file-input" multiple accept="*/*">
</div> </div>
</div> </div>
`; `;
}
initEventListeners() { const dialogueHeader = this.shadowRoot.getElementById('dialogue-header');
const dialogueBox = this.shadowRoot.getElementById('dialogue-box'); const dialogueInput = this.shadowRoot.getElementById('dialogue-input');
const dialogueHeader = this.shadowRoot.getElementById('dialogue-header'); const dialogueSend = this.shadowRoot.getElementById('dialogue-send');
const dialogueInput = this.shadowRoot.getElementById('dialogue-input'); const attachBtn = this.shadowRoot.getElementById('attach-btn');
const dialogueSend = this.shadowRoot.getElementById('dialogue-send'); const fileInput = this.shadowRoot.getElementById('file-input');
const attachBtn = this.shadowRoot.getElementById('attach-btn'); const clearBtn = this.shadowRoot.getElementById('clear-btn');
const fileInput = this.shadowRoot.getElementById('file-input'); const expandBtn = this.shadowRoot.getElementById('expand-btn');
const clearBtn = this.shadowRoot.getElementById('clear-btn');
const expandBtn = this.shadowRoot.getElementById('expand-btn');
dialogueInput.addEventListener('input', () => { dialogueInput.addEventListener('input', () => {
dialogueInput.style.height = 'auto'; dialogueInput.style.height = 'auto';
dialogueInput.style.height = Math.min(dialogueInput.scrollHeight, 120) + 'px'; dialogueInput.style.height = Math.min(dialogueInput.scrollHeight, 120) + 'px';
}); });
dialogueInput.addEventListener('paste', (e) => { dialogueInput.addEventListener('paste', (e) => {
const text = e.clipboardData.getData('text'); const text = e.clipboardData.getData('text');
if (text.length > 1000) { if (text.length > 1000) {
e.preventDefault(); e.preventDefault();
const blob = new Blob([text], { type: 'text/plain' }); const blob = new Blob([text], { type: 'text/plain' });
const file = new File([blob], 'pasted_text.txt', { type: 'text/plain' }); const file = new File([blob], 'pasted_text.txt', { type: 'text/plain' });
this.addFile(file); this.addFile(file);
} }
}); });
dialogueHeader.addEventListener('click', (e) => { dialogueHeader.addEventListener('click', (e) => {
if (e.target === dialogueHeader || e.target.classList.contains('speaker-name')) { this.toggleDialogue();
this.toggleDialogue(); });
}
});
dialogueInput.addEventListener('focus', () => { dialogueInput.addEventListener('focus', () => {
if (!this.isDialogueOpen) this.openDialogue(); if(!this.isDialogueOpen) this.openDialogue();
}); });
clearBtn.addEventListener('click', (e) => { clearBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
this.clearChat(); this.clearChat();
}); });
expandBtn.addEventListener('click', (e) => { expandBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
this.toggleExpand(); this.toggleExpand();
}); });
attachBtn.addEventListener('click', () => fileInput.click()); attachBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => { fileInput.addEventListener('change', (e) => {
Array.from(e.target.files).forEach(file => this.addFile(file)); Array.from(e.target.files).forEach(file => this.addFile(file));
fileInput.value = ''; fileInput.value = '';
}); });
dialogueSend.addEventListener('click', () => { dialogueSend.addEventListener('click', () => {
const buttonText = dialogueSend.textContent; const buttonText = dialogueSend.textContent;
if (buttonText === 'SKIP') this.skipToEnd(); if (buttonText === 'SKIP') this.skipToEnd();
else if (buttonText === 'STOP') this.abortStream(); else if (buttonText === 'STOP') this.abortStream();
else this.sendMessage(); else this.sendMessage();
}); });
dialogueInput.addEventListener('keypress', (e) => { dialogueInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !this.isReceiving) { if (e.key === 'Enter' && !e.shiftKey && !this.isReceiving) {
e.preventDefault(); e.preventDefault();
this.sendMessage(); this.sendMessage();
} }
}); });
} }
clearChat() { clearChat() {
this.messageHistory = []; this.messageHistory = [];
const messageBody = this.shadowRoot.getElementById('message-body'); const messageBody = this.shadowRoot.getElementById('message-body');
messageBody.innerHTML = '<div class="empty-state">NetNavi v1.0.0</div>'; messageBody.innerHTML = '<div class="empty-state">NetNavi v1.0.0</div>';
this.api.clearLLMHistory(); this.navi.clearChat();
} }
toggleExpand() { toggleExpand() {
@@ -607,7 +534,7 @@ class LlmComponent extends HTMLElement {
container.classList.add('has-files'); container.classList.add('has-files');
container.innerHTML = this.attachedFiles.map((file, i) => ` container.innerHTML = this.attachedFiles.map((file, i) => `
<div class="attached-file"> <div class="attached-file">
<span class="file-name" title="${file.name}">📄 ${file.name}</span> <span class="file-name" title="${file.name}">${file.name}</span>
<button class="remove-file" data-index="${i}"></button> <button class="remove-file" data-index="${i}"></button>
</div> </div>
`).join(''); `).join('');
@@ -628,11 +555,6 @@ class LlmComponent extends HTMLElement {
}); });
} }
processMessageForDisplay(text) {
return text.replace(/<file name="([^"]+)">[\s\S]*?<\/file>/g,
'<span class="file-badge">📄 $1</span>');
}
formatTime(date) { formatTime(date) {
const hours = date.getHours().toString().padStart(2, '0'); const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0'); const minutes = date.getMinutes().toString().padStart(2, '0');
@@ -659,16 +581,6 @@ class LlmComponent extends HTMLElement {
})); }));
} }
closeDialogue() {
this.isDialogueOpen = false;
const dialogueBox = this.shadowRoot.getElementById('dialogue-box');
dialogueBox.classList.add('minimized');
this.dispatchEvent(new CustomEvent('dialogue-toggle', {
detail: { isOpen: this.isDialogueOpen }
}));
}
playTextBeep() { playTextBeep() {
const oscillator = this.audioCtx.createOscillator(); const oscillator = this.audioCtx.createOscillator();
const gainNode = this.audioCtx.createGain(); const gainNode = this.audioCtx.createGain();
@@ -709,8 +621,7 @@ class LlmComponent extends HTMLElement {
const cleanText = text.replace(fileRegex, '').trim(); const cleanText = text.replace(fileRegex, '').trim();
const messageWrapper = document.createElement('div'); const messageWrapper = document.createElement('div');
messageWrapper.className = `message-wrapper ${isUser ? 'user' : 'assistant'}`; messageWrapper.className = `message-wrapper ${isUser ? 'user' : 'navi'}`;
const timestamp = this.formatTime(new Date());
const fileBadgesHtml = fileBadges.length > 0 const fileBadgesHtml = fileBadges.length > 0
? `<div class="message-files">${fileBadges.map(name => ? `<div class="message-files">${fileBadges.map(name =>
@@ -718,14 +629,11 @@ class LlmComponent extends HTMLElement {
: ''; : '';
messageWrapper.innerHTML = ` messageWrapper.innerHTML = `
<div class="message-label">${isUser ? 'You' : 'PET'}</div>
${fileBadgesHtml} ${fileBadgesHtml}
<div class="message-bubble">${cleanText}</div> <div class="message-bubble">${cleanText}</div>`;
<div class="message-timestamp">${timestamp}</div>
`;
messageBody.appendChild(messageWrapper); messageBody.appendChild(messageWrapper);
this.messageHistory.push({ text, html: cleanText, isUser, element: messageWrapper, timestamp }); this.messageHistory.push({ text, html: cleanText, isUser, element: messageWrapper, timestamp: Date.now() });
this.scrollToBottom(); this.scrollToBottom();
} }
@@ -745,24 +653,20 @@ class LlmComponent extends HTMLElement {
attachBtn.disabled = true; attachBtn.disabled = true;
clearBtn.disabled = true; clearBtn.disabled = true;
dialogueSend.textContent = 'STOP'; dialogueSend.textContent = 'STOP';
dialogueSend.classList.add('stop'); dialogueSend.setAttribute('color', '#c0392b');
dialogueSend.classList.remove('skip'); attachBtn.setAttribute('disabled', true);
clearBtn.setAttribute('disabled', true);
const emptyState = messageBody.querySelector('.empty-state'); const emptyState = messageBody.querySelector('.empty-state');
if (emptyState) messageBody.innerHTML = ''; if (emptyState) messageBody.innerHTML = '';
const timestamp = this.formatTime(new Date());
const messageWrapper = document.createElement('div'); const messageWrapper = document.createElement('div');
messageWrapper.className = 'message-wrapper assistant'; messageWrapper.className = 'message-wrapper navi';
messageWrapper.innerHTML = ` messageWrapper.innerHTML = `<div class="message-bubble" id="streaming-bubble"></div>`;
<div class="message-label">PET</div>
<div class="message-bubble" id="streaming-bubble"></div>
<div class="message-timestamp">${timestamp}</div>
`;
messageBody.appendChild(messageWrapper); messageBody.appendChild(messageWrapper);
this.currentStreamingMessage = { text: '', html: '', isUser: false, element: messageWrapper, timestamp }; this.currentStreamingMessage = { text: '', html: '', isUser: false, element: messageWrapper, timestamp: Date.now() };
this.messageHistory.push(this.currentStreamingMessage); this.messageHistory.push(this.currentStreamingMessage);
this.scrollToBottom(); this.scrollToBottom();
@@ -779,14 +683,12 @@ class LlmComponent extends HTMLElement {
handleStreamComplete(response) { handleStreamComplete(response) {
this.streamComplete = true; this.streamComplete = true;
if (this.typingIndex >= this.streamBuffer.length) { if (this.typingIndex >= this.streamBuffer.length) {
this.cleanupStreaming(); this.cleanupStreaming();
} else { } else {
const dialogueSend = this.shadowRoot.getElementById('dialogue-send'); const dialogueSend = this.shadowRoot.getElementById('dialogue-send');
dialogueSend.textContent = 'SKIP'; dialogueSend.textContent = 'SKIP';
dialogueSend.classList.remove('stop'); dialogueSend.setAttribute('color', '#f39c12');
dialogueSend.classList.add('skip');
} }
} }
@@ -861,7 +763,9 @@ class LlmComponent extends HTMLElement {
attachBtn.disabled = false; attachBtn.disabled = false;
clearBtn.disabled = false; clearBtn.disabled = false;
dialogueSend.textContent = 'SEND'; dialogueSend.textContent = 'SEND';
dialogueSend.classList.remove('stop', 'skip'); dialogueSend.setAttribute('color', this.navi.theme.accent);
attachBtn.removeAttribute('disabled');
clearBtn.removeAttribute('disabled');
if (bubble) { if (bubble) {
bubble.id = ''; bubble.id = '';
@@ -913,9 +817,7 @@ class LlmComponent extends HTMLElement {
this.renderAttachedFiles(); this.renderAttachedFiles();
// Send via API with streaming callback 💬 // Send via API with streaming callback 💬
this.currentRequest = this.api.sendPetMessage(text, (chunk) => { this.currentRequest = this.navi.ask(text, (chunk) => this.handleStreamChunk(chunk));
this.handleStreamChunk(chunk);
});
// Handle completion/errors with promise // Handle completion/errors with promise
try { try {

View File

@@ -5,15 +5,6 @@ const TILE_WIDTH = 64;
const TILE_HEIGHT = 32; const TILE_HEIGHT = 32;
const TILE_DEPTH = 16; const TILE_DEPTH = 16;
// ============================================
// HAPTIC FEEDBACK
// ============================================
function triggerHaptic() {
if ('vibrate' in navigator) {
navigator.vibrate(10);
}
}
// ============================================ // ============================================
// THEME HANDLER // THEME HANDLER
// ============================================ // ============================================
@@ -183,7 +174,6 @@ function createPet(gridX, gridY, name = 'PET') {
class Game { class Game {
constructor() { constructor() {
this.worldId = ''; this.worldId = '';
this.theme = null;
this.world = null; this.world = null;
this.app = null; this.app = null;
this.pet = null; this.pet = null;
@@ -193,57 +183,51 @@ class Game {
this.keys = {}; this.keys = {};
// Use global singleton 🌍 // Use global singleton 🌍
this.api = window.netNaviAPI; this.navi = window.navi;
this.worldActions = null; this.worldActions = null;
this.playerInfo = { this.playerInfo = {
name: 'Guest', name: 'Guest',
apiUrl: this.api.baseUrl apiUrl: this.navi.navi
}; };
} }
async init() { async init() {
try { try {
// Join world with callbacks 🌍 // Join world with callbacks 🌍
this.worldActions = this.api.joinWorld(this.worldId, { await this.navi.init();
onWorldLoaded: (data) => { this.worldActions = this.navi.connect(this.worldId);
this.world = data.world;
this.theme = data.theme;
applyTheme(this.theme);
this.initializeRenderer();
},
onCurrentPlayers: (players) => { this.worldActions.onData = (data) => {
players.forEach(player => { this.world = data;
if (player.socketId !== this.api.worldSocket.id) { applyTheme(this.world.theme);
this.addOtherPlayer(player); this.initializeRenderer();
} }
});
},
onPlayerJoined: (player) => { this.worldActions.onPlayers = (players) => {
this.addOtherPlayer(player); players.forEach(player => {
}, if (player.name !== this.navi.info.name) {
this.addOtherPlayer(player);
}
});
}
onPlayerMoved: (data) => { this.worldActions.onJoined = (player) => {
const sprite = this.otherPlayers.get(data.socketId); this.addOtherPlayer(player);
if (sprite) { }
this.moveOtherPlayer(sprite, data.x, data.y);
}
},
onPlayerLeft: (data) => { this.worldActions.onMoved = (data) => {
const sprite = this.otherPlayers.get(data.socketId); const sprite = this.otherPlayers.get(data.socketId);
if (sprite) { if (sprite) {
this.app.stage.removeChild(sprite); this.moveOtherPlayer(sprite, data.x, data.y);
this.otherPlayers.delete(data.socketId); }
} };
},
onError: (error) => { this.worldActions.onLeft = (data) => {
console.error('❌ World error:', error); const sprite = this.otherPlayers.get(data.socketId);
} if(sprite) this.otherPlayers.delete(data.socketId);
}); }
this.worldActions.onError = (error) => console.error('❌ World error:', error);
console.log('✨ Game initializing...'); console.log('✨ Game initializing...');
} catch (error) { } catch (error) {
@@ -299,12 +283,13 @@ class Game {
this.app.stage.addChild(tiles); this.app.stage.addChild(tiles);
this.world.tiles.forEach(tileData => { this.world.tiles.forEach(tileData => {
const tile = createTile(tileData, this.theme); const tile = createTile(tileData, this.world.theme);
tile.on('pointerdown', () => this.movePetTo(tile.gridX, tile.gridY)); tile.on('pointerdown', () => this.movePetTo(tile.gridX, tile.gridY));
tiles.addChild(tile); tiles.addChild(tile);
}); });
this.pet = createPet(this.world.pet.startX, this.world.pet.startY, this.playerInfo.name); const spawn = this.world.tiles.find(t => t.type === 'spawn');
this.pet = createPet(spawn.x, spawn.y, this.playerInfo.name);
this.app.stage.addChild(this.pet); this.app.stage.addChild(this.pet);
this.dialogue = document.getElementById('llm'); this.dialogue = document.getElementById('llm');

View File

@@ -12,9 +12,7 @@
<style> <style>
* { * {
margin: 0; box-sizing: border-box !important;
padding: 0;
box-sizing: border-box;
} }
html, body { html, body {
@@ -22,6 +20,8 @@
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
margin: 0;
padding: 0;
} }
*, button, input { *, button, input {
@@ -48,11 +48,12 @@
<jukebox-component id="jukebox"></jukebox-component> <jukebox-component id="jukebox"></jukebox-component>
<llm-component id="llm"></llm-component> <llm-component id="llm"></llm-component>
<script type="module">
<script src="/netnavi-api.js"></script> import Navi from './navi.mjs';
<script>window.netNaviAPI = new NetNaviAPI();</script> window.navi = new Navi();
<script src="/jukebox.js"></script> </script>
<script src="/llm.js"></script> <script type="module" src="/components/jukebox.mjs"></script>
<script src="/world.js"></script> <script type="module" src="/components/llm.mjs"></script>
<script type="module" src="/components/world.mjs"></script>
</body> </body>
</html> </html>

254
public/navi.mjs Normal file
View File

@@ -0,0 +1,254 @@
export default class Navi {
api;
connected = false;
icon;
info;
theme;
world;
#init;
#listeners = new Map();
#socket;
#secret;
#world;
constructor(api = window.location.origin, secret = '') {
this.api = api.replace(/\/$/, '');
this.icon = `${this.api}/favicon.png`;
this.#secret = secret;
}
async init() {
if(this.#init) return this.#init;
this.#init = new Promise(async (res, rej) => {
try {
this.info = await fetch(`${this.api}/api/info`, {
headers: this.#secret ? {'Authorization': `Bearer ${this.#secret}`} : {}
}).then(resp => {
if(!resp.ok) throw new Error(`Invalid Navi API: ${this.api}`);
return resp.json();
});
this.theme = this.info.theme;
this.#socket = io(this.api, {auth: this.#secret ? {token: this.#secret} : null});
this.#socket.on('llm-stream', (chunk) => {
if(this.llmCallback) this.llmCallback(chunk);
this.emit('llm:stream', chunk);
});
this.#socket.on('llm-response', (data) => {
if(this.llmResolve) this.llmResolve(data);
this.emit('llm:response', data);
this.llmCallback = null;
this.llmResolve = null;
this.llmReject = null;
});
this.#socket.on('llm-error', (error) => {
if(this.llmReject) this.llmReject(error);
this.emit('llm:error', error);
this.llmCallback = null;
this.llmResolve = null;
this.llmReject = null;
});
this.connected = true;
this.emit('init');
res(this);
} catch (err) {
rej(err);
}
});
return this.#init;
}
// ============================================
// EVENT SYSTEM
// ============================================
on(event, callback) {
if(!this.#listeners.has(event)) this.#listeners.set(event, []);
this.#listeners.get(event).push(callback);
return () => this.off(event, callback);
}
off(event, callback) {
const callbacks = this.#listeners.get(event);
if(callbacks) {
const index = callbacks.indexOf(callback);
if(index > -1) callbacks.splice(index, 1);
}
}
emit(event, data) {
const callbacks = this.#listeners.get(event) || [];
callbacks.forEach(cb => cb(data));
}
// ============================================
// REST API
// ============================================
async sendUserMessage(userId, message) {
const response = await fetch(`${this.api}/api/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
if (!response.ok) throw new Error('Message failed to send bestie');
const result = await response.json();
this.emit('message:sent', { userId, message, result });
return result;
}
async linkPet(petId, targetApiUrl) {
const response = await fetch(`${this.api}/api/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetApiUrl })
});
if (!response.ok) throw new Error('Link connection is bussin (negatively)');
const result = await response.json();
this.emit('pet:linked', { petId, targetApiUrl, result });
return result;
}
// ============================================
// WORLD SOCKET
// ============================================
connect(apiOrWorld, world) {
let api;
if(world) {
api = apiOrWorld;
} else {
api = this.api;
world = apiOrWorld;
}
if(!this.world) this.world = {};
if(this.#world && this.world.api !== api) {
this.#world.disconnect();
this.#world = null;
}
if(!this.#world) this.#world = io(`${api}/world`, {auth: this.#secret ? {token: this.#secret} : null});
this.world.api = api;
this.world.data = null;
this.world.name = world;
this.world.players = new Map();
const callbacks = {
move: (x, y) => {
this.#world.emit('move', {x, y});
},
leave: () => {
this.#world.disconnect();
this.world = null;
},
onData: (data) => { },
onPlayers: (players) => { },
onJoined: (player) => { },
onMoved: (player) => { },
onLeft: (player) => { },
onError: (error) => { }
}
this.#world.on('data', (data) => {
this.world.data = data;
callbacks.onData(data);
this.emit('world:data', data);
});
this.#world.on('players', (players) => {
this.world.players.clear();
players.forEach(p => this.world.players.set(p.socketId, p));
callbacks.onPlayers(players);
this.emit('world:players', players)
});
this.#world.on('joined', (player) => {
this.world.players.set(player.socketId, player);
callbacks.onJoined(player);
this.emit('world:joined', player);
});
this.#world.on('moved', (data) => {
const player = this.world.players.get(data.socketId);
if(player) {
player.x = data.x;
player.y = data.y;
}
callbacks.onMoved(player);
this.emit('world:moved', player);
});
this.#world.on('left', (data) => {
const player = this.world.players.get(data.socketId);
this.world.players.delete(data.socketId);
callbacks.onLeft(player);
this.emit('world:left', player);
});
this.#world.on('error', (error) => {
console.error('World error:', error);
callbacks.onError(error);
this.emit('world:error', error);
});
this.#world.emit('join', {world, api});
this.emit('world:join', world);
return callbacks;
}
// ============================================
// LLM
// ============================================
ask(message, stream) {
this.llmCallback = stream;
const promise = new Promise((resolve, reject) => {
this.llmResolve = resolve;
this.llmReject = reject;
this.#socket.emit('llm-ask', {message});
this.emit('llm:ask')
});
promise.abort = () => {
this.#socket.emit('llm-abort');
if(this.llmReject) this.llmReject(new Error('Aborted by user'));
this.llmCallback = null;
this.llmResolve = null;
this.llmReject = null;
this.emit('llm:abort')
};
return promise;
}
clearChat() {
if(this.#socket) this.#socket.emit('llm-clear');
this.emit('llm:clear');
}
// ============================================
// UTILITY
// ============================================
disconnect() {
this.connected = false;
this.icon = this.info = this.theme = this.world = this.#init = this.#secret = null;
if(this.#world) {
this.#world.disconnect();
this.#world = null;
}
if(this.#socket) {
this.#socket.disconnect();
this.#socket = null;
}
this.emit('disconnected');
}
}

View File

@@ -1,304 +0,0 @@
class NetNaviAPI {
constructor(baseUrl = window.location.origin) {
if(window.netNaviAPI) return window.netNaviAPI;
this.baseUrl = baseUrl.replace(/\/$/, '');
// State properties
this.petInfo = null;
this.spriteSheet = null;
this.isConnected = false;
this.lastSync = null;
this.currentWorld = null;
this.currentWorldHost = null;
this.currentPlayers = new Map();
// Socket
this.worldSocket = null;
this.llmSocket = io(`${this.baseUrl}/llm`);
this._setupLLMListeners();
this.listeners = new Map();
}
_setupLLMListeners() {
this.llmSocket.on('stream', (chunk) => {
this.emit('llm:stream', chunk);
if (this.currentStreamCallback) this.currentStreamCallback(chunk);
});
this.llmSocket.on('response', (data) => {
this.emit('llm:response', data);
if (this.currentResolve) this.currentResolve(data);
this.currentStreamCallback = null;
this.currentResolve = null;
this.currentReject = null;
});
this.llmSocket.on('error', (error) => {
console.error('❌ LLM socket error:', error);
this.emit('llm:error', error);
if (this.currentReject) this.currentReject(error);
this.currentStreamCallback = null;
this.currentResolve = null;
this.currentReject = null;
});
}
// ============================================
// EVENT SYSTEM
// ============================================
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
return () => this.off(event, callback);
}
off(event, callback) {
const callbacks = this.listeners.get(event);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) callbacks.splice(index, 1);
}
}
emit(event, data) {
const callbacks = this.listeners.get(event) || [];
callbacks.forEach(cb => cb(data));
}
// ============================================
// REST API
// ============================================
async getPetInfo(petId, forceRefresh = false) {
if (this.petInfo && !forceRefresh) {
return this.petInfo;
}
const response = await fetch(`${this.baseUrl}/api/info`);
if (!response.ok) throw new Error('Failed to fetch PET info fr fr');
this.petInfo = await response.json();
this.lastSync = Date.now();
this.emit('petInfo:updated', this.petInfo);
return this.petInfo;
}
async getSpriteSheet(petId, forceRefresh = false) {
if (this.spriteSheet && !forceRefresh) {
return this.spriteSheet;
}
const response = await fetch(`${this.baseUrl}/api/sprite`);
if (!response.ok) throw new Error('Sprite sheet is cooked 💀');
this.spriteSheet = await response.json();
this.emit('sprite:updated', this.spriteSheet);
return this.spriteSheet;
}
async sendUserMessage(userId, message) {
const response = await fetch(`${this.baseUrl}/api/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
if (!response.ok) throw new Error('Message failed to send bestie');
const result = await response.json();
this.emit('message:sent', { userId, message, result });
return result;
}
async linkPet(petId, targetApiUrl) {
const response = await fetch(`${this.baseUrl}/api/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetApiUrl })
});
if (!response.ok) throw new Error('Link connection is bussin (negatively)');
const result = await response.json();
this.emit('pet:linked', { petId, targetApiUrl, result });
return result;
}
// ============================================
// WORLD SOCKET
// ============================================
joinWorld(worldId, callbacks = {}) {
// Parse the worldId to check if it includes a host
let targetHost = this.baseUrl;
let actualWorldId = worldId;
// Check if worldId is a URL (like "http://other-server.com/worldName")
if(worldId?.startsWith('http://') || worldId?.startsWith('https://')) {
const url = new URL(worldId);
targetHost = url.origin;
actualWorldId = url.pathname.replace(/^\//, '') || 'default';
}
// Leave current world first if we're switching 🚪
if (this.worldSocket && this.currentWorld) {
this.worldSocket.emit('player-leave'); // Let server know we're bouncing
if (this.currentWorldHost !== targetHost) {
this.worldSocket.disconnect();
this.worldSocket = null;
}
}
// Initialize world socket for the target host 🔌
if (!this.worldSocket) {
this.worldSocket = io(targetHost);
this.currentWorldHost = targetHost;
this.isConnected = true;
this.emit('connection:changed', true);
}
this.currentWorld = actualWorldId;
// Auto-build playerInfo from state 💪
const playerInfo = {
name: this.petInfo?.name || 'Guest',
apiUrl: this.baseUrl
};
// Setup socket listeners with provided callbacks 📡
this.worldSocket.on('world-data', (data) => {
this.emit('world:loaded', data);
if (callbacks.onWorldLoaded) callbacks.onWorldLoaded(data);
});
this.worldSocket.on('current-players', (players) => {
this.currentPlayers.clear();
players.forEach(p => this.currentPlayers.set(p.socketId, p));
this.emit('world:currentPlayers', players);
if (callbacks.onCurrentPlayers) callbacks.onCurrentPlayers(players);
});
this.worldSocket.on('player-joined', (player) => {
this.currentPlayers.set(player.socketId, player);
this.emit('world:playerJoined', player);
if (callbacks.onPlayerJoined) callbacks.onPlayerJoined(player);
});
this.worldSocket.on('player-moved', (data) => {
const player = this.currentPlayers.get(data.socketId);
if (player) {
player.x = data.x;
player.y = data.y;
}
this.emit('world:playerMoved', data);
if (callbacks.onPlayerMoved) callbacks.onPlayerMoved(data);
});
this.worldSocket.on('player-left', (data) => {
this.currentPlayers.delete(data.socketId);
this.emit('world:playerLeft', data);
if (callbacks.onPlayerLeft) callbacks.onPlayerLeft(data);
});
this.worldSocket.on('error', (error) => {
console.error('❌ World socket error:', error);
this.emit('world:error', error);
if (callbacks.onError) callbacks.onError(error);
});
// Join the world 🌍
this.worldSocket.emit('join-world', { worldId: actualWorldId, playerInfo });
// Return actions dictionary for sending updates 📤
return {
move: (x, y) => {
this.worldSocket.emit('player-move', { x, y });
},
leave: () => {
this.worldSocket.disconnect();
this.worldSocket = null;
this.currentWorld = null;
this.currentWorldHost = null;
this.currentPlayers.clear();
this.isConnected = false;
this.emit('connection:changed', false);
},
reconnect: () => {
if (!this.worldSocket || !this.worldSocket.connected) {
this.worldSocket = io(this.currentWorldHost || targetHost);
this.worldSocket.emit('join-world', { worldId: actualWorldId, playerInfo });
this.isConnected = true;
this.emit('connection:changed', true);
}
}
};
}
// ============================================
// LLM SOCKET
// ============================================
sendPetMessage(message, onStreamCallback) {
this.currentStreamCallback = onStreamCallback;
const promise = new Promise((resolve, reject) => {
this.currentResolve = resolve;
this.currentReject = reject;
this.llmSocket.emit('message', {message, apiUrl: this.baseUrl});
});
promise.abort = () => {
this.llmSocket.emit('abort');
if(this.currentReject) this.currentReject(new Error('Aborted by user'));
this.currentStreamCallback = null;
this.currentResolve = null;
this.currentReject = null;
};
return promise;
}
clearLLMHistory() {
if(this.llmSocket) {
this.llmSocket.emit('clear');
this.emit('llm:cleared');
}
}
disconnectLLM() {
if(this.llmSocket) {
this.llmSocket.disconnect();
this.llmSocket = null;
}
}
// ============================================
// UTILITY
// ============================================
getState() {
return {
petInfo: this.petInfo,
spriteSheet: this.spriteSheet,
isConnected: this.isConnected,
lastSync: this.lastSync,
currentWorld: this.currentWorld,
currentPlayers: Array.from(this.currentPlayers.values())
};
}
disconnect() {
if (this.worldSocket) {
this.worldSocket.disconnect();
this.worldSocket = null;
}
if (this.llmSocket) {
this.llmSocket.disconnect();
this.llmSocket = null;
}
this.isConnected = false;
this.emit('connection:changed', false);
}
}

View File

@@ -6,21 +6,61 @@ import fs from 'fs';
import { join, dirname } from 'path'; import { join, dirname } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import {Ai, DateTimeTool, ExecTool, FetchTool, ReadWebpageTool, WebSearchTool} from '@ztimson/ai-utils'; import {Ai, DateTimeTool, ExecTool, FetchTool, ReadWebpageTool, WebSearchTool} from '@ztimson/ai-utils';
import {contrast, shadeColor} from '@ztimson/utils';
// ============================================
// Settings
// ============================================
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = join(dirname(__filename), '..');
const storage = join(__dirname, 'storage');
const navi = join(storage, 'navi');
const protocols = join(storage, 'protocols');
const worlds = join(storage, 'worlds');
const logoFile = join(navi, 'logo.png');
const settingsFile = join(navi, 'settings.json');
const memoriesFile = join(navi, 'memories.json');
let updated = false;
const app = express(); function calcColors(theme) {
const httpServer = createServer(app); return {
...theme,
backgroundContrast: contrast(theme.background),
backgroundDark: shadeColor(theme.background, -.1),
backgroundLight: shadeColor(theme.background, .1),
primaryContrast: contrast(theme.primary),
primaryDark: shadeColor(theme.primary, -.1),
primaryLight: shadeColor(theme.primary, .1),
accentContrast: contrast(theme.accent),
accentDark: shadeColor(theme.accent, -.1),
accentLight: shadeColor(theme.accent, .1),
mutedContrast: contrast(theme.muted),
mutedDark: shadeColor(theme.muted, -.1),
mutedLight: shadeColor(theme.muted, .1),
}
}
let memories = [], settings = {}; let memories = [], settings = {
const settingsFile = join(__dirname, '../navi', 'settings.json'); name: 'Navi',
const memoriesFile = join(__dirname, '../navi', 'memories.json'); personality: 'You are inquisitive about your user trying to best adjust your personally to fit them',
const logoFile = join(__dirname, '../navi', 'logo.png'); instructions: '',
theme: {
background: '#fff',
border: '#000',
text: '#252525',
primary: '#9f32ef',
accent: '#6f16c3',
muted: '#a8a8a8',
}
};
function load() { function load() {
try { try {
settings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8')); settings = {
...settings,
...JSON.parse(fs.readFileSync(settingsFile, 'utf-8'))
};
} catch { } } catch { }
try { try {
memories = JSON.parse(fs.readFileSync(memoriesFile, 'utf-8')); memories = JSON.parse(fs.readFileSync(memoriesFile, 'utf-8'));
@@ -28,6 +68,9 @@ function load() {
} }
function save() { function save() {
if(!updated) return;
updated = false;
const dir = dirname(settingsFile); const dir = dirname(settingsFile);
if (!fs.existsSync(dir)) { if (!fs.existsSync(dir)) {
fs.mkdir(dir, { recursive: true }, (err) => { fs.mkdir(dir, { recursive: true }, (err) => {
@@ -38,11 +81,6 @@ function save() {
fs.writeFileSync(memoriesFile, JSON.stringify(memories, null, 2)); fs.writeFileSync(memoriesFile, JSON.stringify(memories, null, 2));
} }
function shutdown() {
save();
process.exit(0);
}
load(); load();
const ai = new Ai({ const ai = new Ai({
llm: { llm: {
@@ -56,13 +94,19 @@ const ai = new Ai({
args: {instructions: {type: 'string', description: 'Bullet point list of how to behave'}}, args: {instructions: {type: 'string', description: 'Bullet point list of how to behave'}},
fn: (args) => { fn: (args) => {
settings.personality = args.instructions; settings.personality = args.instructions;
save(); updated = true;
return 'done!'; return 'done!';
} }
}], }],
}, },
}); });
// ============================================
// Setup
// ============================================
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, { const io = new Server(httpServer, {
cors: {origin: "*", methods: ["GET", "POST"]} cors: {origin: "*", methods: ["GET", "POST"]}
}); });
@@ -72,133 +116,145 @@ app.use(express.json());
app.use(express.static('public')); app.use(express.static('public'));
// ============================================ // ============================================
// WORLD MANAGEMENT // Primary Socket
// ============================================ // ============================================
const worldPlayers = new Map();
const chatHistory = new Map(); const chatHistory = new Map();
// Load world data
function loadWorld(worldId) {
try {
const worldPath = join(__dirname, '../worlds', worldId || '', 'world.json');
const world = JSON.parse(fs.readFileSync(worldPath, 'utf-8'));
const themePath = join(__dirname, '../worlds', worldId || '', world.theme);
const theme = JSON.parse(fs.readFileSync(themePath, 'utf-8'));
worldPlayers.set(worldId, new Map());
return {world, theme};
} catch (error) {
console.error(`Failed to load world ${worldId}:`, error);
return null;
}
}
// ============================================
// SOCKET.IO - WORLD CHANNELS
// ============================================
io.on('connection', (socket) => { io.on('connection', (socket) => {
console.debug('🔌 Client connected:', socket.id); console.debug('👤 User connected:', socket.id);
let currentWorld = null;
let playerData = null;
// Join a world
socket.on('join-world', (data) => {
const { worldId, playerInfo } = data;
const worldData = loadWorld(worldId);
if(!worldData) return socket.emit('error', { message: 'World not found' });
// Leave previous world if any
if(currentWorld) {
socket.leave(`world:${currentWorld}`);
const players = worldPlayers.get(currentWorld);
if(players) {
players.delete(socket.id);
socket.to(`world:${currentWorld}`).emit('player-left', {socketId: socket.id});
}
}
// Join new world
currentWorld = worldId;
playerData = {
socketId: socket.id,
name: playerInfo.name,
apiUrl: playerInfo.apiUrl,
x: worldData.world.pet.startX,
y: worldData.world.pet.startY
};
socket.join(`world:${worldId}`);
const players = worldPlayers.get(worldId);
players.set(socket.id, playerData);
socket.emit('world-data', worldData);
const currentPlayers = Array.from(players.values());
socket.emit('current-players', currentPlayers);
socket.to(`world:${worldId}`).emit('player-joined', playerData);
});
// Player movement
socket.on('player-move', (data) => {
if(!currentWorld || !playerData) return;
const { x, y } = data;
playerData.x = x;
playerData.y = y;
socket.to(`world:${currentWorld}`).emit('player-moved', {socketId: socket.id, x, y});
});
// Disconnect
socket.on('disconnect', () => {
console.debug('🔌 Client disconnected:', socket.id);
if(currentWorld) {
const players = worldPlayers.get(currentWorld);
if(players) {
players.delete(socket.id);
socket.to(`world:${currentWorld}`).emit('player-left', {socketId: socket.id});
}
}
});
});
// ============================================
// LLM CHANNEL
// ============================================
const petNamespace = io.of('/llm');
petNamespace.on('connection', (socket) => {
chatHistory.set(socket.id, []); chatHistory.set(socket.id, []);
let currentRequest = null; let currentRequest = null;
socket.on('clear', async () => { socket.on('llm-clear', async () => {
chatHistory.set(socket.id, []); chatHistory.set(socket.id, []);
}); });
socket.on('abort', () => { socket.on('llm-abort', () => {
if (currentRequest?.abort) { if (currentRequest?.abort) {
currentRequest.abort(); currentRequest.abort();
currentRequest = null; currentRequest = null;
} }
}); });
socket.on('message', async (data) => { socket.on('llm-ask', async (data) => {
const { message, apiUrl } = data; const { message } = data;
const history = chatHistory.get(socket.id); const history = chatHistory.get(socket.id);
currentRequest = ai.language.ask(message, { currentRequest = ai.language.ask(message, {
history, history,
memory: memories, memory: memories,
stream: (chunk) => socket.emit('stream', chunk) stream: (chunk) => socket.emit('llm-stream', chunk)
}).then(resp => { }).then(resp => {
chatHistory.set(socket.id, history); chatHistory.set(socket.id, history);
socket.emit('response', { message: resp }); socket.emit('llm-response', { message: resp });
updated = true;
}).catch(err => { }).catch(err => {
socket.emit('error', {message: err.message || err.toString()}); socket.emit('llm-error', {message: err.message || err.toString()});
}).finally(() => { }).finally(() => {
currentRequest = null; currentRequest = null;
}); });
}); });
socket.on('disconnect', () => { socket.on('disconnect', () => {
console.log('🔌 LLM Client disconnected:', socket.id); console.log('👤 User disconnected:', socket.id);
chatHistory.delete(socket.id); chatHistory.delete(socket.id);
}); });
}); });
// ============================================
// World Socket
// ============================================
const worldPlayers = new Map();
// Load world data
function loadWorld(name) {
let w, t;
try {
w = JSON.parse(fs.readFileSync(join(worlds, (name || 'home') + '.json'), 'utf-8'));
} catch (error) {
console.error(`Failed to load world ${name}:`, error);
return null;
}
try {
t = JSON.parse(fs.readFileSync(join(protocols, w.theme + '.json'), 'utf-8'));
t.colors = calcColors(t.colors);
} catch (error) {
console.error(`Failed to load theme protocol ${w.theme}:`, error);
return null;
}
worldPlayers.set(name, new Map());
return {...w, theme: t};
}
io.of('/world').on('connection', (socket) => {
console.debug('🌍 Navi joined world:', socket.id);
let currentWorld = null;
let playerData = null;
// Join a world
socket.on('join', async (data) => {
const { world, api } = data;
const info = await fetch(api.replace(/\/$/, '') + '/api/info').then(resp => {
if(resp.ok) return resp.json();
socket.emit('error', {message: `Invalid Navi API: ${api}`});
return resp.error;
});
const worldData = loadWorld(world);
if(!worldData) return socket.emit('error', { message: 'World not found' });
// Leave previous world if any
if(currentWorld) {
socket.leave(`world:${currentWorld}`);
const players = worldPlayers.get(currentWorld);
if(players) {
players.delete(socket.id);
socket.to(`world:${currentWorld}`).emit('left', {socketId: socket.id});
}
}
// Join new world
currentWorld = world;
const spawn = worldData.tiles.find(t => t.type === 'spawn');
playerData = {
...info,
socketId: socket.id,
navi: api,
x: spawn.x,
y: spawn.y
};
socket.join(`world:${world}`);
const players = worldPlayers.get(world);
players.set(socket.id, playerData);
socket.emit('data', worldData);
const currentPlayers = Array.from(players.values());
socket.emit('players', currentPlayers);
socket.to(`world:${world}`).emit('joined', playerData);
});
// Player movement
socket.on('move', (data) => {
if(!currentWorld || !playerData) return;
const { x, y } = data;
playerData.x = x;
playerData.y = y;
socket.to(`world:${currentWorld}`).emit('moved', {socketId: socket.id, x, y});
});
// Disconnect
socket.on('disconnect', () => {
console.debug('🌍 Navi disconnected:', socket.id);
if(currentWorld) {
const players = worldPlayers.get(currentWorld);
if(players) {
players.delete(socket.id);
socket.to(`world:${currentWorld}`).emit('left', {socketId: socket.id});
}
}
});
});
// ============================================ // ============================================
// REST API ENDPOINTS // REST API ENDPOINTS
// ============================================ // ============================================
@@ -207,21 +263,12 @@ app.get('/favicon.*', (req, res) => {
res.sendFile(logoFile); res.sendFile(logoFile);
}); });
// Get PET info // Get Navi info
app.get('/api/info', (req, res) => { app.get('/api/info', (req, res) => {
const { petId } = req.params;
// TODO: Fetch from database
res.json({ res.json({
id: petId, name: settings.name,
name: 'MyCoolPET', theme: calcColors(settings.theme),
owner: 'player1', });
bandwidth: 75,
shards: [],
stats: {
level: 5,
health: 100
}
});
}); });
// Get sprite sheet // Get sprite sheet
@@ -268,11 +315,15 @@ app.post('/api/link', (req, res) => {
// ============================================ // ============================================
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
setInterval(() => save(), 5 * 60_000);
httpServer.listen(PORT, () => { httpServer.listen(PORT, () => {
loadWorld();
console.log('✅ Home world loaded');
console.log(`🚀 Server running on: http://localhost:${PORT}`); console.log(`🚀 Server running on: http://localhost:${PORT}`);
}); });
function shutdown() {
save();
process.exit(0);
}
process.on('SIGINT', shutdown); process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown); process.on('SIGTERM', shutdown);

View File

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

View File

@@ -0,0 +1,26 @@
{
"name": "Evas Glade",
"version": "1.0.0",
"type": "theme",
"icon": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 640 640\"><path d=\"M535.3 70.7C541.7 64.6 551 62.4 559.6 65.2C569.4 68.5 576 77.7 576 88L576 274.9C576 406.1 467.9 512 337.2 512C260.2 512 193.8 462.5 169.7 393.3C134.3 424.1 112 469.4 112 520C112 533.3 101.3 544 88 544C74.7 544 64 533.3 64 520C64 445.1 102.2 379.1 160.1 340.3C195.4 316.7 237.5 304 280 304L360 304C373.3 304 384 293.3 384 280C384 266.7 373.3 256 360 256L280 256C240.3 256 202.7 264.8 169 280.5C192.3 210.5 258.2 160 336 160C402.4 160 451.8 137.9 484.7 116C503.9 103.2 520.2 87.9 535.4 70.7z\"/></svg>",
"background": {
"image": "/assets/background.jpg",
"style": "cover"
},
"music": ["/assets/music.mp3"],
"colors": {
"tileTop": "#7a5a8c",
"tileSide": "#4a2d5a",
"tileHighlight": "#a17acf",
"gridColor": "#5e2f6a",
"gridHighlight": "#ff75b5",
"background": "#e6e6fa",
"border": "#000000",
"text": "#2d2524",
"dialogueInputBg": "#f0e6ff",
"primary": "#aa33ff",
"accent": "#8a2be2",
"muted": "#ff75b5"
}
}

View File

@@ -1,7 +1,8 @@
{ {
"name": "Home World", "name": "Home World",
"version": "1.0.0", "version": "1.0.0",
"theme": "./theme.json", "theme": "theme_default",
"gridSize": 8, "gridSize": 8,
"tiles": [ "tiles": [
{"x": 0, "y": 0, "type": "floor"}, {"x": 0, "y": 0, "type": "floor"},
@@ -40,7 +41,7 @@
{"x": 1, "y": 4, "type": "floor"}, {"x": 1, "y": 4, "type": "floor"},
{"x": 2, "y": 4, "type": "floor"}, {"x": 2, "y": 4, "type": "floor"},
{"x": 3, "y": 4, "type": "floor"}, {"x": 3, "y": 4, "type": "floor"},
{"x": 4, "y": 4, "type": "floor"}, {"x": 4, "y": 4, "type": "spawn"},
{"x": 5, "y": 4, "type": "floor"}, {"x": 5, "y": 4, "type": "floor"},
{"x": 6, "y": 4, "type": "floor"}, {"x": 6, "y": 4, "type": "floor"},
{"x": 7, "y": 4, "type": "floor"}, {"x": 7, "y": 4, "type": "floor"},
@@ -68,9 +69,5 @@
{"x": 5, "y": 7, "type": "floor"}, {"x": 5, "y": 7, "type": "floor"},
{"x": 6, "y": 7, "type": "floor"}, {"x": 6, "y": 7, "type": "floor"},
{"x": 7, "y": 7, "type": "floor"} {"x": 7, "y": 7, "type": "floor"}
], ]
"pet": {
"startX": 4,
"startY": 4
}
} }

View File

@@ -1,27 +0,0 @@
{
"name": "Evas Glade 🌿",
"version": "1.0.0",
"type": "theme",
"background": {
"image": "/assets/background.jpg",
"style": "cover"
},
"music": "/assets/music.mp3",
"colors": {
"tileTop": "#7a5a8c",
"tileSide": "#4a2d5a",
"tileHighlight": "#a17acf",
"gridColor": "#5e2f6a",
"gridHighlight": "#ff75b5",
"dialogueBg": "#e6e6fa",
"dialogueBorder": "#000000",
"dialogueHeaderBg": "#aa33ff",
"dialogueInputBg": "#f0e6ff",
"dialogueText": "#2d2524",
"buttonBg": "#8a2be2",
"buttonText": "#ffffff",
"buttonShadow": "#5a3a7d",
"muteButtonBg": "#ff75b5",
"muteButtonBorder": "#a17acf"
}
}