Initial commit
This commit is contained in:
394
js/demos/chat.js
Normal file
394
js/demos/chat.js
Normal file
@@ -0,0 +1,394 @@
|
||||
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: '0–30s', 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;
|
||||
Reference in New Issue
Block a user