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

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 {
constructor() {
super();
this.attachShadow({ mode: 'open' });
// Use global singleton 🎵
this.api = window.netNaviAPI;
this.navi = window.navi;
this.navi.init();
this.unsubscribeWorld = this.navi.on('world:data', (data) => {
console.log(data, this.navi.world.data);
this.render()
});
this.playlist = [];
this.currentTrackIndex = 0;
this.bgMusic = null;
this.isMuted = false;
this.hasInteracted = false;
this.theme = null;
this.isPlaylistMode = false;
}
connectedCallback() {
this.render();
this.setupAPIListeners();
}
disconnectedCallback() {
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() {
this.shadowRoot.innerHTML = `
<style>
@@ -44,8 +37,8 @@ class JukeboxComponent extends HTMLElement {
display: flex;
top: 20px;
right: 20px;
background: var(--dialogue-header-bg, #ffffff);
border: 3px solid var(--dialogue-border, #000);
background: ${this.navi.world.data.theme.colors.primary};
border: 3px solid ${this.navi.world.data.theme.colors.border};
border-radius: 8px;
padding: 12px;
z-index: 1000;
@@ -55,8 +48,9 @@ class JukeboxComponent extends HTMLElement {
.track-info {
display: flex;
align-items: center;
background: var(--dialogue-bg, #8b5cf6);
color: var(--dialogue-text, #ffffff);
background: ${this.navi.world.data.theme.colors.background};
border: 2px solid ${this.navi.world.data.theme.colors.border};
color: ${this.navi.world.data.theme.colors.text};
padding: 8px;
border-radius: 4px;
margin: 0 8px;
@@ -64,6 +58,7 @@ class JukeboxComponent extends HTMLElement {
height: 24px;
width: 120px;
position: relative;
cursor: text;
}
.track-name {
white-space: nowrap;
@@ -77,69 +72,37 @@ class JukeboxComponent extends HTMLElement {
0% { transform: translateX(0); }
100% { transform: translateX(-100%); }
}
.controls-row {
display: flex;
gap: 8px;
justify-content: center;
align-items: center;
.hidden {
display: none;
}
.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>
<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">
<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>
</button>
</btn-component>
<div class="audio-controls hidden" id="playlist-controls">
<div class="controls-row">
<button class="control-btn" id="prev-btn">
<svg viewBox="0 0 24 24">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
</svg>
</button>
</div>
<btn-component id="prev-btn" color="${this.navi.world.data.theme.colors.accent}">
<svg viewBox="0 0 24 24">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
</svg>
</btn-component>
<div class="track-info">
<span class="track-name" id="track-name">No track loaded</span>
</div>
<div class="controls-row">
<button class="control-btn" id="next-btn">
<div style="display: flex; gap: 8px">
<btn-component id="next-btn" color="${this.navi.world.data.theme.colors.accent}">
<svg viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
</svg>
</button>
<button class="control-btn" id="mute-btn">
</btn-component>
<btn-component id="mute-btn" color="#c0392b">
<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"/>
</svg>
</button>
</btn-component>
</div>
</div>
`;
@@ -147,18 +110,15 @@ class JukeboxComponent extends HTMLElement {
this.shadowRoot.getElementById('mute-btn').addEventListener('click', () => this.toggleMute());
this.shadowRoot.getElementById('prev-btn').addEventListener('click', () => this.previousTrack());
this.shadowRoot.getElementById('next-btn').addEventListener('click', () => this.nextTrack());
this.loadMusic(this.navi.world.data.theme.music);
}
loadMusic(musicConfig, theme) {
if (!musicConfig) return;
this.theme = theme;
loadMusic(musicConfig) {
if(!musicConfig) return;
this.isPlaylistMode = Array.isArray(musicConfig);
this.playlist = Array.isArray(musicConfig) ? musicConfig : [musicConfig];
this.currentTrackIndex = 0;
this.applyThemeColors();
if (this.isPlaylistMode) {
this.shadowRoot.getElementById('simple-mute-btn').classList.add('hidden');
this.shadowRoot.getElementById('playlist-controls').classList.remove('hidden');
@@ -205,15 +165,6 @@ class JukeboxComponent extends HTMLElement {
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() {
const startMusic = () => {
if (!this.hasInteracted && !this.isMuted && this.bgMusic) {

View File

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

View File

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

View File

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