Files
Space-Game/archive/legacy-static/js/demos/chat.js
francy51 316a44661b Restructure into pnpm monorepo with game shell, docs, and SpacetimeDB backend
- Restructure flat static prototype into pnpm workspace monorepo
- apps/game: playable shell with R3F 3D scene, HUD, SpacetimeDB connection
- apps/docs: design docs and prototypes
- apps/site: landing page
- packages/ui: shared Button and Panel primitives
- services/spacetimedb: backend module (9 tables, 11 reducers)
- Archive legacy static files to archive/legacy-static/
- Game loop: connect, undock, target, approach, dock, mine, sell
- Add pnpm-workspace.yaml, tsconfig.base.json, spacetime.json
2026-05-31 17:56:56 -04:00

395 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
window.GDD = window.GDD || {};
const { useState, useEffect, useCallback, useRef } = React;
function ChatDemo() {
// ── State ──
const [activeChannel, setActiveChannel] = useState('local');
const [messages, setMessages] = useState([]);
const [inputText, setInputText] = useState('');
const [playerName] = useState('CMDR Kimura');
const [playerSystem] = useState('Jita');
const [simTime, setSimTime] = useState(0);
const [speed, setSpeed] = useState(1);
const [running, setRunning] = useState(false);
const messagesEndRef = useRef(null);
const tickRef = useRef(null);
// ── Simulated players in different systems ──
const players = [
{ name: 'CMDR Vasquez', system: 'Jita', distance: 0 },
{ name: 'CMDR Chen', system: 'Jita', distance: 0 },
{ name: 'CMDR Okafor', system: 'Amarr', distance: 6 },
{ name: 'CMDR Lindström', system: 'Amarr', distance: 6 },
{ name: 'CMDR Tanaka', system: 'Rens', distance: 12 },
{ name: 'CMDR Dubois', system: 'Hek', distance: 18 },
{ name: 'CMDR Voronov', system: 'PF-346', distance: 32 },
];
// ── Channel definitions ──
const channels = [
{ id: 'local', name: 'Local', range: 'Current System', delay: 'Instant', icon: '📡', color: 'var(--fg-bright)' },
{ id: 'trade', name: 'Trade', range: 'Station / Region', delay: '030s', icon: '💰', color: 'var(--green)' },
{ id: 'private', name: 'Private (Okafor)', range: 'Distance-based', delay: '~12s (6 jumps)', icon: '✉', color: 'var(--cyan)' },
{ id: 'fleet', name: 'Fleet [Post-MVP]', range: 'Fleet members', delay: 'Instant', icon: '🚀', color: 'var(--purple)', disabled: true },
];
// ── Pre-seeded message corpus ──
const seedMessages = {
local: [
{ from: 'CMDR Vasquez', text: 'Anyone seen a Veldspar belt that isn\'t depleted?', time: -45 },
{ from: 'CMDR Chen', text: 'Try belt 7-2, was full 10 min ago', time: -38 },
{ from: 'CMDR Vasquez', text: 'Thanks, warping now', time: -35 },
{ from: 'CMDR Chen', text: 'Watch out, saw a Corpii frigate on scan near 7-2', time: -30 },
{ from: 'CMDR Vasquez', text: 'Corpii? In high-sec? That\'s unusual', time: -25 },
{ from: 'System', text: '⚠ CONCORD response dispatched in Jita — criminal act in progress near Jita IV', time: -20, isSystem: true },
{ from: 'CMDR Chen', text: 'Well that explains the locals being jumpy', time: -15 },
],
trade: [
{ from: 'CMDR Chen', text: 'WTS 5000 Tritanium @ 3.15/unit — Jita IV docked', time: -60 },
{ from: 'CMDR Vasquez', text: 'WTB Nocxium x200, paying market +5%. Jita.', time: -50 },
{ from: 'Market Bot', text: '📊 Scordite volume up 340% in Jita in last hour. Spread widening.', time: -40, isSystem: true },
{ from: 'CMDR Chen', text: 'That Scordite spike is because someone bought out the entire sell wall at Jita IV', time: -30 },
],
private: [
{ from: 'CMDR Okafor', text: 'Hey, you still in Jita?', time: -120 },
{ from: 'CMDR Okafor', text: 'Megacyte just crashed 15% in Amarr. Someone panic-sold a freighter load.', time: -90 },
{ from: 'CMDR Okafor', text: 'If you can haul fast, there\'s a 20% spread between Amarr buy and Jita sell', time: -75 },
],
};
// ── Delay calculation ──
const getDelay = (fromDistance) => {
if (fromDistance === 0) return 0;
return Math.round(2 * Math.sqrt(fromDistance));
};
const formatDelay = (seconds) => {
if (seconds === 0) return 'instant';
if (seconds < 60) return `~${seconds}s`;
return `~${Math.floor(seconds / 60)}m ${seconds % 60}s`;
};
// ── Simulation ──
useEffect(() => {
if (!running) {
if (tickRef.current) clearInterval(tickRef.current);
return;
}
tickRef.current = setInterval(() => {
setSimTime(t => t + speed);
}, 500);
return () => clearInterval(tickRef.current);
}, [running, speed]);
// Seed initial messages
useEffect(() => {
const initial = [];
Object.entries(seedMessages).forEach(([channel, msgs]) => {
msgs.forEach(msg => {
initial.push({
id: `${channel}-${msg.time}-${msg.from}`,
channel,
from: msg.from,
text: msg.text,
isSystem: msg.isSystem || false,
timestamp: msg.time,
deliveredAt: msg.time + (channel === 'private' ? getDelay(6) : 0),
delay: channel === 'private' ? getDelay(6) : 0,
status: 'delivered',
});
});
});
initial.sort((a, b) => a.deliveredAt - b.deliveredAt);
setMessages(initial);
}, []);
// Check for delayed message delivery
useEffect(() => {
if (!running) return;
setMessages(prev => prev.map(m => {
if (m.status === 'pending' && simTime >= m.deliveredAt) {
return { ...m, status: 'delivered' };
}
return m;
}));
}, [simTime, running]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const sendMessage = () => {
if (!inputText.trim()) return;
const now = simTime;
let delay = 0;
if (activeChannel === 'private') delay = getDelay(6);
if (activeChannel === 'trade') delay = Math.floor(Math.random() * 30);
const msg = {
id: `user-${Date.now()}`,
channel: activeChannel,
from: playerName,
text: inputText.trim(),
isSystem: false,
timestamp: now,
deliveredAt: now + delay,
delay,
status: delay === 0 ? 'delivered' : 'pending',
};
setMessages(prev => [...prev, msg]);
setInputText('');
// Simulate NPC response in local
if (activeChannel === 'local' && Math.random() > 0.5) {
const responder = players.filter(p => p.system === playerSystem && p.name !== playerName);
if (responder.length > 0) {
const responder_ = responder[Math.floor(Math.random() * responder.length)];
const responses = [
'Copy that.',
'Interesting. Keep us posted.',
'Acknowledged.',
'Seen it. Be careful out there.',
'Good luck.',
];
setTimeout(() => {
setMessages(prev => [...prev, {
id: `npc-${Date.now()}`,
channel: 'local',
from: responder_.name,
text: responses[Math.floor(Math.random() * responses.length)],
isSystem: false,
timestamp: simTime + 3,
deliveredAt: simTime + 3,
delay: 0,
status: 'delivered',
}]);
}, 1500);
}
}
};
const visibleMessages = messages.filter(m => {
if (m.channel !== activeChannel) return false;
if (m.status === 'pending') return true;
return true;
});
const channelInfo = channels.find(c => c.id === activeChannel);
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: '0', background: 'var(--surface-base)' }}>
{/* Header */}
<div style={{ padding: 'var(--sp-3) var(--sp-4)', borderBottom: '1px solid var(--border)', background: 'var(--surface-raised)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--sp-3)' }}>
<a href="#overview" style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', fontSize: '0.7rem', fontFamily: 'var(--font-mono)', color: 'var(--muted)', textDecoration: 'none', padding: '2px 8px', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', transition: 'color 0.15s, border-color 0.15s' }} onMouseEnter={e => { e.currentTarget.style.color='var(--fg-bright)'; e.currentTarget.style.borderColor='var(--border-light)'; }} onMouseLeave={e => { e.currentTarget.style.color='var(--muted)'; e.currentTarget.style.borderColor='var(--border)'; }}> Docs</a>
<div>
<h3 style={{ margin: 0, fontSize: '1rem' }}>💬 Communication System Demo</h3>
<span style={{ fontSize: '0.75rem', color: 'var(--muted)' }}>Location: {playerSystem} Simulating light-speed delay</span>
</div>
</div>
<div style={{ display: 'flex', gap: 'var(--sp-2)', alignItems: 'center' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--muted)' }}>Speed:</span>
{[0.5, 1, 2, 5].map(s => (
<button key={s} className={`btn btn-sm${speed === s ? ' btn-primary' : ''}`}
onClick={() => setSpeed(s)} style={{ padding: '2px 8px', fontSize: '0.7rem' }}>
{s}×
</button>
))}
<button className={`btn btn-sm${running ? '' : ' btn-primary'}`}
onClick={() => { setRunning(!running); if (!running) setSimTime(t => t); }}
style={{ padding: '2px 12px', fontSize: '0.75rem', marginLeft: 'var(--sp-2)' }}>
{running ? '⏸ Pause' : '▶ Run'}
</button>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', color: 'var(--accent)', marginLeft: 'var(--sp-2)' }}>
T+{simTime.toFixed(0)}s
</span>
</div>
</div>
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* Channel sidebar */}
<div style={{ width: '200px', borderRight: '1px solid var(--border)', background: 'var(--surface-sunken)', padding: 'var(--sp-3)', flexShrink: 0 }}>
<div style={{ fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 'var(--sp-2)' }}>
Channels
</div>
{channels.map(ch => (
<button key={ch.id}
disabled={ch.disabled}
onClick={() => setActiveChannel(ch.id)}
style={{
display: 'flex', alignItems: 'center', gap: 'var(--sp-2)',
width: '100%', padding: 'var(--sp-2) var(--sp-3)',
borderRadius: 'var(--radius-sm)', border: 'none',
background: activeChannel === ch.id ? 'var(--surface-raised)' : 'transparent',
color: ch.disabled ? 'var(--muted)' : ch.color,
cursor: ch.disabled ? 'not-allowed' : 'pointer',
opacity: ch.disabled ? 0.4 : 1,
fontSize: '0.82rem', textAlign: 'left', marginBottom: '2px',
}}>
<span>{ch.icon}</span>
<span>{ch.name}</span>
</button>
))}
<div style={{ marginTop: 'var(--sp-5)', fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 'var(--sp-2)' }}>
Nearby Pilots
</div>
{players.filter(p => p.system === playerSystem).map(p => (
<div key={p.name} style={{ fontSize: '0.75rem', color: 'var(--fg-dim)', padding: '2px var(--sp-3)' }}>
<span style={{ color: 'var(--green)' }}></span> {p.name}
</div>
))}
<div style={{ marginTop: 'var(--sp-4)', fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 'var(--sp-2)' }}>
Distant Pilots
</div>
{players.filter(p => p.system !== playerSystem).map(p => (
<div key={p.name} style={{ fontSize: '0.75rem', color: 'var(--fg-dim)', padding: '2px var(--sp-3)' }}>
<span style={{ color: 'var(--amber)' }}></span> {p.name}
<span style={{ color: 'var(--muted)', fontSize: '0.65rem', marginLeft: 'var(--sp-1)' }}>({p.distance}j)</span>
</div>
))}
</div>
{/* Main chat area */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Channel info bar */}
<div style={{ padding: 'var(--sp-2) var(--sp-4)', borderBottom: '1px solid var(--border)', background: 'var(--surface-raised)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--sp-3)' }}>
<span style={{ fontSize: '1rem' }}>{channelInfo?.icon}</span>
<span style={{ fontWeight: 600, color: channelInfo?.color }}>{channelInfo?.name}</span>
<span className="pill" style={{ fontSize: '0.6rem', background: 'var(--surface-sunken)', color: 'var(--fg-dim)' }}>
{channelInfo?.range}
</span>
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
Delay: {channelInfo?.delay}
</div>
</div>
{/* Messages */}
<div style={{ flex: 1, overflowY: 'auto', padding: 'var(--sp-3) var(--sp-4)' }}>
{visibleMessages.length === 0 && (
<div style={{ textAlign: 'center', color: 'var(--muted)', fontSize: '0.85rem', padding: 'var(--sp-8)' }}>
No messages yet. Press Run to start the simulation.
</div>
)}
{visibleMessages.map(msg => (
<div key={msg.id} style={{
marginBottom: 'var(--sp-2)',
padding: 'var(--sp-2) var(--sp-3)',
borderRadius: 'var(--radius-sm)',
background: msg.isSystem ? 'rgba(245, 158, 11, 0.08)' : msg.status === 'pending' ? 'rgba(100, 100, 100, 0.08)' : 'transparent',
borderLeft: msg.isSystem ? '2px solid var(--amber)' : msg.status === 'pending' ? '2px solid var(--muted)' : '2px solid transparent',
opacity: msg.status === 'pending' ? 0.5 : 1,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '2px' }}>
<span style={{
fontWeight: 600, fontSize: '0.82rem',
color: msg.isSystem ? 'var(--amber)' :
msg.from === playerName ? 'var(--accent)' : 'var(--cyan)',
}}>
{msg.isSystem ? '⚠ System' : msg.from}
</span>
<span style={{ fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
{msg.status === 'pending'
? `⏳ delivering… (${formatDelay(msg.delay)} light-speed delay)`
: msg.delay > 0
? `T+${msg.deliveredAt.toFixed(0)}s (delayed ${formatDelay(msg.delay)})`
: `T+${msg.timestamp.toFixed(0)}s`}
</span>
</div>
<div style={{ fontSize: '0.85rem', color: msg.isSystem ? 'var(--amber)' : 'var(--fg-dim)' }}>
{msg.text}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div style={{ padding: 'var(--sp-3) var(--sp-4)', borderTop: '1px solid var(--border)', background: 'var(--surface-raised)', display: 'flex', gap: 'var(--sp-3)' }}>
<input
type="text"
value={inputText}
onChange={e => setInputText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') sendMessage(); }}
placeholder={activeChannel === 'private' ? `Message CMDR Okafor (${formatDelay(getDelay(6))} delay)...` : `Send to ${channelInfo?.name}...`}
style={{
flex: 1, padding: 'var(--sp-2) var(--sp-3)',
borderRadius: 'var(--radius-sm)', border: '1px solid var(--border)',
background: 'var(--surface-base)', color: 'var(--fg)',
fontSize: '0.85rem', outline: 'none',
}}
/>
<button className="btn btn-primary" onClick={sendMessage} style={{ padding: 'var(--sp-2) var(--sp-4)', fontSize: '0.85rem' }}>
Send {activeChannel === 'private' ? `(${formatDelay(getDelay(6))})` : ''}
</button>
</div>
</div>
{/* Right sidebar: delay visualization */}
<div style={{ width: '220px', borderLeft: '1px solid var(--border)', background: 'var(--surface-sunken)', padding: 'var(--sp-3)', overflowY: 'auto', flexShrink: 0 }}>
<div style={{ fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 'var(--sp-3)' }}>
Light-Speed Delay Map
</div>
<div style={{ fontSize: '0.7rem', color: 'var(--fg-dim)', marginBottom: 'var(--sp-4)', lineHeight: 1.5 }}>
Messages to/from pilots in other systems travel at light speed. Formula: <code style={{ fontFamily: 'var(--font-mono)', fontSize: '0.65rem' }}>2 × (jumps)</code> seconds.
</div>
{players.map(p => {
const delay = getDelay(p.distance);
const isLocal = p.distance === 0;
return (
<div key={p.name} style={{
padding: 'var(--sp-2) var(--sp-3)',
marginBottom: '4px',
borderRadius: 'var(--radius-sm)',
background: isLocal ? 'rgba(34, 197, 94, 0.08)' : 'var(--surface-raised)',
borderLeft: `3px solid ${isLocal ? 'var(--green)' : delay > 15 ? 'var(--red)' : delay > 5 ? 'var(--amber)' : 'var(--cyan)'}`,
}}>
<div style={{ fontSize: '0.75rem', color: 'var(--fg)', fontWeight: 500 }}>{p.name}</div>
<div style={{ fontSize: '0.65rem', color: 'var(--muted)', display: 'flex', justifyContent: 'space-between' }}>
<span>{p.system}</span>
<span style={{ color: isLocal ? 'var(--green)' : delay > 15 ? 'var(--red)' : 'var(--amber)' }}>
{isLocal ? 'instant' : `~${delay}s`}
</span>
</div>
{p.distance > 0 && (
<div style={{
marginTop: '4px', height: '3px', borderRadius: '2px',
background: 'var(--surface-raised)', overflow: 'hidden',
}}>
<div style={{
width: `${Math.min(100, (delay / 40) * 100)}%`,
height: '100%',
background: delay > 15 ? 'var(--red)' : delay > 5 ? 'var(--amber)' : 'var(--cyan)',
borderRadius: '2px',
transition: 'width 0.3s ease',
}} />
</div>
)}
</div>
);
})}
<div style={{ marginTop: 'var(--sp-5)', fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 'var(--sp-2)' }}>
What This Validates
</div>
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.75rem', margin: 0, paddingLeft: 'var(--sp-4)', lineHeight: 1.8 }}>
<li>Light-speed delay <em>feels</em> meaningful not instant</li>
<li>Private messages arrive after a visible wait</li>
<li>Local chat is instant, creating information asymmetry</li>
<li>Trade channel has moderate delay (regional relay)</li>
<li>System messages (CONCORD, market) are immediate</li>
</ul>
</div>
</div>
</div>
);
}
window.GDD.ChatDemo = ChatDemo;