fix(v2): full v1-style radar canvas + inventory icon composites

Radar — now pixel-accurate reproduction of v1:
- 300×300 canvas with dark circular background
- Semi-transparent dereth.png map overlay (heading-rotated)
- 4 range rings + crosshair lines
- Compass labels (N=red, E/S/W=gray) rotating with heading
- Facing direction indicator line
- Entity dots color-coded by type (Monster=red, Player=blue,
  NPC=green, Portal=purple, Corpse=orange, Container=yellow)
- Player dot: gold center with white border
- Heading-up rotation for all entity positions
- Click to select entity (white selection ring)
- Scroll to zoom (0.02-5.0 AC units range)
- Entity list with color dot, name, type, distance, compass direction
- Selected entity highlighted with blue left border

Inventory — v1-style icon composites + slot styling:
- 3-layer icon composite: underlay → base → overlay images
  using portal.dat offset formula + icon_overlay_id/IntValues
- Equipment slots: 3D beveled border + cyan glow when equipped
  (matching v1's outset border + #00ffff shadow)
- Pack item cells: purple gradient background (v1's #3d007a)
- Proper 36×36px icon rendering with pixelated scaling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 19:16:21 +02:00
parent e5c982d6f5
commit cf078b7765
6 changed files with 437 additions and 183 deletions

View file

@ -23,11 +23,26 @@ interface Item {
} }
// Icon helper: convert raw icon ID to hex filename // Icon helper: convert raw icon ID to hex filename
function iconHex(raw: number): string {
if (!raw || raw <= 0) return '06000133';
return (raw + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
}
function iconUrl(item: Item): string { function iconUrl(item: Item): string {
const raw = item.icon ?? item.Icon ?? 0; return `/icons/${iconHex(item.icon ?? item.Icon ?? 0)}.png`;
if (raw === 0) return '/icons/06000133.png'; // fallback }
const hex = (raw + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); function overlayUrl(item: Item): string | null {
return `/icons/${hex}.png`; const id = (item as any).icon_overlay_id;
if (id && id > 0) return `/icons/${iconHex(id)}.png`;
const iv = item.IntValues;
if (iv?.['218103849'] && Number(iv['218103849']) > 100) return `/icons/${iconHex(Number(iv['218103849']))}.png`;
return null;
}
function underlayUrl(item: Item): string | null {
const id = (item as any).icon_underlay_id;
if (id && id > 0) return `/icons/${iconHex(id)}.png`;
const iv = item.IntValues;
if (iv?.['218103850'] && Number(iv['218103850']) > 100) return `/icons/${iconHex(Number(iv['218103850']))}.png`;
return null;
} }
function itemName(item: Item): string { return item.name ?? item.Name ?? 'Unknown'; } function itemName(item: Item): string { return item.name ?? item.Name ?? 'Unknown'; }
@ -77,12 +92,16 @@ const SLOT_BG: Record<number, string> = {};
[2,4,134217728,268435456,536870912,1073741824].forEach(m => SLOT_BG[m] = '#1e3e3e'); [2,4,134217728,268435456,536870912,1073741824].forEach(m => SLOT_BG[m] = '#1e3e3e');
[2097152,1048576,4194304,16777216,33554432,8388608].forEach(m => SLOT_BG[m] = '#142040'); [2097152,1048576,4194304,16777216,33554432,8388608].forEach(m => SLOT_BG[m] = '#142040');
function ItemIcon({ item, size = 38 }: { item: Item; size?: number }) { function ItemIcon({ item, size = 36 }: { item: Item; size?: number }) {
const under = underlayUrl(item);
const over = overlayUrl(item);
const imgStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, width: size, height: size, border: 'none', background: 'transparent', imageRendering: 'pixelated' };
return ( return (
<div title={itemTooltip(item)} style={{ width: size, height: size, position: 'relative', cursor: 'help' }}> <div title={itemTooltip(item)} style={{ width: size, height: size, position: 'relative', cursor: 'help' }}>
<img src={iconUrl(item)} alt={itemName(item)} {under && <img src={under} alt="" style={{ ...imgStyle, zIndex: 1 }} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />}
style={{ width: '100%', height: '100%', objectFit: 'contain', imageRendering: 'pixelated' }} <img src={iconUrl(item)} alt={itemName(item)} style={{ ...imgStyle, zIndex: 2 }}
onError={e => { (e.target as HTMLImageElement).src = '/icons/06000133.png'; }} /> onError={e => { (e.target as HTMLImageElement).src = '/icons/06000133.png'; }} />
{over && <img src={over} alt="" style={{ ...imgStyle, zIndex: 3 }} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />}
</div> </div>
); );
} }
@ -154,12 +173,13 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
<div key={slot.key} title={item ? itemTooltip(item) : slot.name} <div key={slot.key} title={item ? itemTooltip(item) : slot.name}
style={{ style={{
position: 'absolute', left: (slot.col - 1) * 44 + 4, top: (slot.row - 1) * 44 + 4, position: 'absolute', left: (slot.col - 1) * 44 + 4, top: (slot.row - 1) * 44 + 4,
width: 40, height: 40, background: item ? slot.bg : `${slot.bg}55`, width: 36, height: 36, background: item ? '#5a5a62' : '#3a3a42',
border: `1px solid ${item ? '#555' : '#2a2a2a'}`, borderRadius: 3, border: item ? '2px solid #00ffff' : '2px outset #6a6a72',
boxShadow: item ? '0 0 5px #00ffff, inset 0 0 5px rgba(0,255,255,0.2)' : 'none',
display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden',
}}> }}>
{item ? <ItemIcon item={item} size={36} /> : {item ? <ItemIcon item={item} size={32} /> :
<span style={{ fontSize: '0.45rem', color: '#444', textAlign: 'center', lineHeight: 1 }}>{slot.name}</span>} <span style={{ fontSize: '0.42rem', color: '#555', textAlign: 'center', lineHeight: 1 }}>{slot.name}</span>}
</div> </div>
); );
})} })}
@ -171,9 +191,10 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexWrap: 'wrap', gap: 2, padding: 4, alignContent: 'flex-start' }}> <div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexWrap: 'wrap', gap: 2, padding: 4, alignContent: 'flex-start' }}>
{activeItems.map((item, i) => ( {activeItems.map((item, i) => (
<div key={item.item_id ?? item.Id ?? i} title={itemTooltip(item)} <div key={item.item_id ?? item.Id ?? i} title={itemTooltip(item)}
style={{ width: 40, height: 40, background: '#1a1a1a', border: '1px solid #2a2a2a', borderRadius: 2, style={{ width: 36, height: 36, background: 'linear-gradient(135deg, #3d007a 0%, #1a0033 100%)',
border: '1px solid #4a148c',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'help', overflow: 'hidden' }}> display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'help', overflow: 'hidden' }}>
<ItemIcon item={item} size={36} /> <ItemIcon item={item} size={32} />
</div> </div>
))} ))}
</div> </div>

View file

@ -1,70 +1,305 @@
import React, { useEffect } from 'react'; import React, { useEffect, useRef, useState, useCallback } from 'react';
import { DraggableWindow } from './DraggableWindow'; import { DraggableWindow } from './DraggableWindow';
const CANVAS_SIZE = 300;
const DEFAULT_RANGE = 0.5; // AC units, ~120m
const RADAR_COLORS: Record<string, string> = {
Monster: '#ff4444', Player: '#4488ff', NPC: '#44cc44', Vendor: '#44cc44',
Portal: '#aa44ff', Corpse: '#ff8800', Container: '#cccc44', Door: '#888888',
};
function compassDir(angleDeg: number): string {
const a = ((angleDeg % 360) + 360) % 360;
const dirs = ['N','NE','E','SE','S','SW','W','NW'];
return dirs[Math.round(a / 45) % 8];
}
interface NearbyObject { interface NearbyObject {
id: number; id: number; name: string; object_class?: string; type?: string;
name: string; ew?: number; ns?: number; distance?: number; bearing?: number;
type: string; raw_x?: number; raw_y?: number;
distance: number; _px?: number; _py?: number;
bearing?: number;
} }
interface Props { interface Props {
id: string; id: string; charName: string; zIndex: number;
charName: string;
zIndex: number;
socket: WebSocket | null; socket: WebSocket | null;
nearbyObjects: NearbyObject[]; radarData: any; // full nearby_objects message
} }
export const RadarWindow: React.FC<Props> = ({ id, charName, zIndex, socket, nearbyObjects }) => { export const RadarWindow: React.FC<Props> = ({ id, charName, zIndex, socket, radarData }) => {
// Send start_radar when window opens, stop_radar on close const canvasRef = useRef<HTMLCanvasElement>(null);
const rangeRef = useRef(DEFAULT_RANGE);
const [range, setRange] = useState(DEFAULT_RANGE);
const [selectedId, setSelectedId] = useState<number | null>(null);
const mapImgRef = useRef<HTMLImageElement | null>(null);
const objectsRef = useRef<NearbyObject[]>([]);
// Load map image once
useEffect(() => { useEffect(() => {
if (socket && socket.readyState === WebSocket.OPEN) { const img = new Image();
img.src = '/dereth.png';
img.onload = () => { mapImgRef.current = img; };
}, []);
// Send start_radar on open, stop_radar on close
useEffect(() => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ player_name: charName, command: 'start_radar' })); socket.send(JSON.stringify({ player_name: charName, command: 'start_radar' }));
} }
return () => { return () => {
if (socket && socket.readyState === WebSocket.OPEN) { if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ player_name: charName, command: 'stop_radar' })); socket.send(JSON.stringify({ player_name: charName, command: 'stop_radar' }));
} }
}; };
}, [charName, socket]); }, [charName, socket]);
const objects = nearbyObjects || []; // Scroll to zoom
const sorted = [...objects].sort((a, b) => (a.distance ?? 999) - (b.distance ?? 999)); const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const factor = e.deltaY > 0 ? 1.25 : 0.8;
rangeRef.current = Math.max(0.02, Math.min(5.0, rangeRef.current * factor));
setRange(rangeRef.current);
}, []);
// Click to select
const handleCanvasClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
const my = (e.clientY - rect.top) * (canvas.height / rect.height);
let closestObj: NearbyObject | null = null;
let closestDist = 20;
objectsRef.current.forEach(obj => {
if (obj._px === undefined) return;
const d = Math.sqrt((mx - obj._px) ** 2 + (my - obj._py!) ** 2);
if (d < closestDist) { closestDist = d; closestObj = obj; }
});
setSelectedId(closestObj ? (closestObj as NearbyObject).id : null);
}, []);
// Render canvas
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !radarData) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const size = CANVAS_SIZE;
const cx = size / 2, cy = size / 2;
const objects: NearbyObject[] = radarData.objects ?? [];
const playerEW = radarData.player_ew ?? 0;
const playerNS = radarData.player_ns ?? 0;
const heading = radarData.player_heading ?? 0;
const isDungeon = radarData.is_dungeon ?? false;
const playerX = radarData.player_x ?? 0;
const playerY = radarData.player_y ?? 0;
const currentRange = rangeRef.current;
const scale = isDungeon ? (size / 2) / (currentRange * 240) : (size / 2) / currentRange;
const headingRad = heading * Math.PI / 180;
// Clear + dark circle background
ctx.clearRect(0, 0, size, size);
ctx.fillStyle = '#111';
ctx.beginPath();
ctx.arc(cx, cy, cx, 0, Math.PI * 2);
ctx.fill();
// Clip to circle
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, cx - 1, 0, Math.PI * 2);
ctx.clip();
// Semi-transparent map background (overworld)
if (!isDungeon && mapImgRef.current) {
const mapImg = mapImgRef.current;
const pixPerCoord = mapImg.naturalWidth / 204.2;
const mapCenterX = (playerEW + 102.1) * pixPerCoord;
const mapCenterY = (102.1 - playerNS) * pixPerCoord;
ctx.globalAlpha = 0.4;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(-headingRad);
ctx.drawImage(mapImg,
mapCenterX - (cx / scale) * pixPerCoord,
mapCenterY - (cy / scale) * pixPerCoord,
(size / scale) * pixPerCoord,
(size / scale) * pixPerCoord,
-cx, -cy, size, size);
ctx.restore();
ctx.globalAlpha = 1.0;
}
ctx.restore();
// Range rings (4)
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
for (let i = 1; i <= 4; i++) {
ctx.beginPath();
ctx.arc(cx, cy, (cx / 4) * i, 0, Math.PI * 2);
ctx.stroke();
}
// Crosshairs
ctx.beginPath();
ctx.moveTo(cx, 0); ctx.lineTo(cx, size);
ctx.moveTo(0, cy); ctx.lineTo(size, cy);
ctx.stroke();
// Compass labels
ctx.font = 'bold 12px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
[{ l: 'N', a: 0 }, { l: 'E', a: Math.PI / 2 }, { l: 'S', a: Math.PI }, { l: 'W', a: -Math.PI / 2 }].forEach(({ l, a }) => {
const ra = a - headingRad;
ctx.fillStyle = l === 'N' ? '#cc4444' : '#888';
ctx.fillText(l, cx + Math.sin(ra) * (cx - 12), cy - Math.cos(ra) * (cx - 12));
});
// Facing line
ctx.strokeStyle = '#666';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx, cy - cx * 0.85);
ctx.stroke();
// Entity dots
const rotAngle = isDungeon ? (Math.PI - headingRad) : headingRad;
const cosA = Math.cos(rotAngle), sinA = Math.sin(rotAngle);
objects.forEach(obj => {
let dX: number, dY: number;
if (isDungeon && obj.raw_x !== undefined) {
dX = -(obj.raw_x - playerX);
dY = (obj.raw_y! - playerY);
} else {
dX = (obj.ew ?? 0) - playerEW;
dY = (obj.ns ?? 0) - playerNS;
}
const dx = dX * cosA - dY * sinA;
const dy = isDungeon ? (dX * sinA + dY * cosA) : -(dX * sinA + dY * cosA);
const px = cx + dx * scale;
const py = cy + dy * scale;
const distFromCenter = Math.sqrt((px - cx) ** 2 + (py - cy) ** 2);
if (distFromCenter > cx - 4) return;
obj._px = px;
obj._py = py;
const objClass = obj.object_class ?? obj.type ?? '';
const color = RADAR_COLORS[objClass] ?? '#888';
const isSel = obj.id === selectedId;
const dotSize = isSel ? 6 : (objClass === 'Monster' || objClass === 'Player') ? 4 : 3;
if (isSel) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(px, py, dotSize + 3, 0, Math.PI * 2);
ctx.stroke();
}
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
ctx.fill();
if (objClass === 'Player' || objClass === 'Portal' || isSel) {
ctx.fillStyle = isSel ? '#fff' : color;
ctx.font = '9px monospace';
ctx.textAlign = 'left';
ctx.fillText(obj.name, px + 6, py + 3);
}
});
objectsRef.current = objects;
// Player dot (center)
ctx.fillStyle = '#ffcc00';
ctx.beginPath();
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.stroke();
}, [radarData, range, selectedId]);
// Entity list with distance + direction
const entities = (radarData?.objects ?? []).map((obj: any) => {
const pEW = radarData?.player_ew ?? 0;
const pNS = radarData?.player_ns ?? 0;
const isDungeon = radarData?.is_dungeon ?? false;
const pX = radarData?.player_x ?? 0;
const pY = radarData?.player_y ?? 0;
let dX: number, dY: number, dist: number;
if (isDungeon && obj.raw_x !== undefined) {
dX = -(obj.raw_x - pX); dY = obj.raw_y - pY;
dist = Math.sqrt(dX * dX + dY * dY);
} else {
dX = (obj.ew ?? 0) - pEW; dY = (obj.ns ?? 0) - pNS;
dist = Math.sqrt(dX * dX + dY * dY) * 240;
}
const angle = Math.atan2(dX, dY) * 180 / Math.PI;
return { ...obj, dist, dir: compassDir(angle) };
}).sort((a: any, b: any) => a.dist - b.dist);
const rangeMeters = Math.round(range * 240);
return ( return (
<DraggableWindow id={id} title={`Radar: ${charName}`} zIndex={zIndex} width={480} height={420}> <DraggableWindow id={id} title={`Radar: ${charName}`} zIndex={zIndex} width={360} height={560}>
<div style={{ padding: '4px 8px', fontSize: '0.7rem', color: '#888', borderBottom: '1px solid #333' }}> {/* Controls */}
Range: ~120m &middot; {objects.length} objects nearby <div style={{ padding: '4px 8px', display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', color: '#888', borderBottom: '1px solid #333', background: '#1a1a1a' }}>
<span>Range: ~{rangeMeters}m</span>
<span style={{ fontSize: '0.65rem', color: '#555' }}>Scroll to zoom</span>
</div> </div>
<div style={{ flex: 1, overflowY: 'auto', fontSize: '0.73rem' }}>
{objects.length === 0 ? ( {/* Canvas */}
<div style={{ padding: 20, color: '#555', textAlign: 'center' }}> <canvas ref={canvasRef} width={CANVAS_SIZE} height={CANVAS_SIZE}
Waiting for radar data from plugin... style={{ display: 'block', margin: '0 auto', borderBottom: '1px solid #333', cursor: 'crosshair', flexShrink: 0 }}
onWheel={handleWheel} onClick={handleCanvasClick} />
{/* Entity list */}
<div style={{ flex: 1, overflowY: 'auto', fontSize: '0.72rem', minHeight: 0 }}>
{/* Header */}
<div style={{ display: 'flex', padding: '3px 6px', borderBottom: '1px solid #333', color: '#666', fontSize: '0.65rem', fontWeight: 600 }}>
<span style={{ width: 8 }}></span>
<span style={{ flex: 1, marginLeft: 6 }}>Name</span>
<span style={{ width: 55, textAlign: 'left' }}>Type</span>
<span style={{ width: 40, textAlign: 'right' }}>Dist</span>
<span style={{ width: 24, textAlign: 'center' }}>Dir</span>
</div>
{entities.length === 0 && (
<div style={{ padding: 12, color: '#555', textAlign: 'center', fontSize: '0.7rem' }}>
Waiting for radar data...
</div> </div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid #444', color: '#777', fontSize: '0.65rem', textTransform: 'uppercase' }}>
<th style={{ textAlign: 'left', padding: '4px 6px' }}>Name</th>
<th style={{ textAlign: 'left', padding: '4px 4px' }}>Type</th>
<th style={{ textAlign: 'right', padding: '4px 6px' }}>Distance</th>
</tr>
</thead>
<tbody>
{sorted.map((obj, i) => (
<tr key={obj.id ?? i} style={{ borderBottom: '1px solid #1a1a1a', color: '#ccc' }}>
<td style={{ padding: '3px 6px', fontWeight: 500 }}>{obj.name}</td>
<td style={{ padding: '3px 4px', color: '#888', fontSize: '0.68rem' }}>{obj.type || ''}</td>
<td style={{ textAlign: 'right', padding: '3px 6px', fontVariantNumeric: 'tabular-nums' }}>
{obj.distance != null ? `${Math.round(obj.distance)}m` : ''}
</td>
</tr>
))}
</tbody>
</table>
)} )}
{entities.map((obj: any) => {
const objClass = obj.object_class ?? obj.type ?? '';
const color = RADAR_COLORS[objClass] ?? '#888';
const isSel = obj.id === selectedId;
return (
<div key={obj.id} onClick={() => setSelectedId(isSel ? null : obj.id)}
style={{
display: 'flex', alignItems: 'center', padding: '2px 6px',
borderBottom: '1px solid #1a1a1a', cursor: 'pointer', color: '#ccc',
background: isSel ? '#1a2a3a' : '', borderLeft: isSel ? '2px solid #4488ff' : '2px solid transparent',
}}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0 }}></span>
<span style={{ flex: 1, marginLeft: 6, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{obj.name}</span>
<span style={{ width: 55, color: '#888', fontSize: '0.65rem' }}>{objClass}</span>
<span style={{ width: 40, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
{obj.dist < 1000 ? `${Math.round(obj.dist)}m` : `${(obj.dist / 1000).toFixed(1)}km`}
</span>
<span style={{ width: 24, textAlign: 'center', color: '#666' }}>{obj.dir}</span>
</div>
);
})}
</div> </div>
</DraggableWindow> </DraggableWindow>
); );

View file

@ -36,11 +36,9 @@ export const WindowRenderer: React.FC<Props> = ({ characters, chatMessages, near
return <CharacterWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />; return <CharacterWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
case 'inv': case 'inv':
return <InventoryWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />; return <InventoryWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
case 'radar': { case 'radar':
const radarData = nearbyObjects.get(charName);
return <RadarWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} return <RadarWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
socket={socket} nearbyObjects={radarData?.objects ?? []} />; socket={socket} radarData={nearbyObjects.get(charName) ?? null} />;
}
case 'combat': case 'combat':
return <CombatStatsWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />; return <CombatStatsWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
case 'issues': case 'issues':

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mosswart Overlord v2</title> <title>Mosswart Overlord v2</title>
<link rel="icon" type="image/png" href="/icons/7735.png" /> <link rel="icon" type="image/png" href="/icons/7735.png" />
<script type="module" crossorigin src="/v2/assets/index-WeObtuRG.js"></script> <script type="module" crossorigin src="/v2/assets/index-B0m9x7R9.js"></script>
<link rel="stylesheet" crossorigin href="/v2/assets/index-DrsM2PEe.css"> <link rel="stylesheet" crossorigin href="/v2/assets/index-DrsM2PEe.css">
</head> </head>
<body> <body>