Compare commits

...

1 Commits

Author SHA1 Message Date
Timmy
72958a5147 feat(#1542): Avatar customization — color, shape, name tag
Some checks failed
CI / test (pull_request) Failing after 1m3s
Review Approval Gate / verify-review (pull_request) Failing after 6s
CI / validate (pull_request) Failing after 42s
Implements window.AvatarCustomization module (index.html already
has the script tag at line 398 but the file was missing).

Features:
  - 8 color presets (Cyan, Gold, Violet, Rose, Emerald, Orange,
    Ice, Silver)
  - 3 shapes (Sphere, Capsule, Diamond)
  - Name tag above avatar (sprite with canvas texture)
  - Persistent via localStorage (nexus_avatar_settings)
  - Settings panel (bottom-right button)
  - Public API: setColor, setName, setShape, getSettings

Fixes #1542
2026-04-17 01:52:37 -04:00

291
avatar-customization.js Normal file
View File

@@ -0,0 +1,291 @@
/**
* Avatar Customization Module (#1542)
*
* Provides avatar color, shape, and name tag for the Nexus 3D world.
* Persists settings in localStorage.
*
* Usage:
* window.AvatarCustomization.init(scene, camera) — called from init()
* window.AvatarCustomization.update(playerPos) — called each frame
*/
(function () {
'use strict';
// ═══ Configuration ═══════════════════════════════════════════
const STORAGE_KEY = 'nexus_avatar_settings';
const COLOR_PRESETS = [
{ name: 'Cyan', hex: '#4af0c0' },
{ name: 'Gold', hex: '#ffd700' },
{ name: 'Violet', hex: '#7b5cff' },
{ name: 'Rose', hex: '#ff6b9d' },
{ name: 'Emerald', hex: '#50c878' },
{ name: 'Orange', hex: '#ff8c42' },
{ name: 'Ice', hex: '#88ccff' },
{ name: 'Silver', hex: '#c0c0c0' },
];
const SHAPE_OPTIONS = [
{ name: 'Sphere', geometry: 'sphere' },
{ name: 'Capsule', geometry: 'capsule' },
{ name: 'Diamond', geometry: 'diamond' },
];
// ═══ State ═══════════════════════════════════════════════════
let avatarMesh = null;
let nameTag = null;
let scene = null;
let settings = loadSettings();
// ═══ Persistence ═════════════════════════════════════════════
function loadSettings() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved);
} catch (_) { /* ignore */ }
return {
color: COLOR_PRESETS[0].hex,
shape: 'sphere',
name: 'Visitor',
};
}
function saveSettings() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch (_) { /* ignore */ }
}
// ═══ Mesh Creation ══════════════════════════════════════════
function createGeometry(shape) {
switch (shape) {
case 'capsule':
return new THREE.CapsuleGeometry(0.3, 0.6, 8, 16);
case 'diamond':
return new THREE.OctahedronGeometry(0.4, 0);
case 'sphere':
default:
return new THREE.SphereGeometry(0.35, 32, 32);
}
}
function createAvatar() {
if (avatarMesh) {
scene.remove(avatarMesh);
avatarMesh.geometry.dispose();
avatarMesh.material.dispose();
}
const geometry = createGeometry(settings.shape);
const material = new THREE.MeshStandardMaterial({
color: new THREE.Color(settings.color),
emissive: new THREE.Color(settings.color).multiplyScalar(0.3),
metalness: 0.6,
roughness: 0.3,
});
avatarMesh = new THREE.Mesh(geometry, material);
avatarMesh.castShadow = true;
scene.add(avatarMesh);
// Update name tag
updateNameTag();
}
// ═══ Name Tag ═══════════════════════════════════════════════
function createNameTagSprite() {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 64;
const ctx = canvas.getContext('2d');
// Background
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.roundRect(0, 0, 256, 64, 8);
ctx.fill();
// Text
ctx.fillStyle = settings.color;
ctx.font = 'bold 28px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(settings.name, 128, 32);
const texture = new THREE.CanvasTexture(canvas);
const material = new THREE.SpriteMaterial({ map: texture, transparent: true });
const sprite = new THREE.Sprite(material);
sprite.scale.set(1.5, 0.4, 1);
return sprite;
}
function updateNameTag() {
if (nameTag) {
avatarMesh.remove(nameTag);
nameTag.material.map.dispose();
nameTag.material.dispose();
}
nameTag = createNameTagSprite();
nameTag.position.y = 0.8;
avatarMesh.add(nameTag);
}
// ═══ Settings UI ════════════════════════════════════════════
function createSettingsPanel() {
const existing = document.getElementById('avatar-settings-panel');
if (existing) existing.remove();
const panel = document.createElement('div');
panel.id = 'avatar-settings-panel';
panel.style.cssText = `
position: fixed; bottom: 80px; right: 20px; z-index: 1000;
background: rgba(10, 15, 40, 0.95); border: 1px solid #4af0c0;
border-radius: 8px; padding: 16px; min-width: 220px;
font-family: monospace; color: #aaa; display: none;
`;
// Title
panel.innerHTML = `
<div style="color: #4af0c0; font-weight: bold; margin-bottom: 12px; font-size: 14px;">
✦ Avatar Settings
</div>
<!-- Name -->
<div style="margin-bottom: 12px;">
<label style="display: block; margin-bottom: 4px; font-size: 11px; color: #888;">Name</label>
<input id="avatar-name-input" type="text" value="${settings.name}"
style="width: 100%; background: #0a0f28; border: 1px solid #1a2a4a; color: #ddd;
padding: 4px 8px; border-radius: 4px; font-family: monospace; box-sizing: border-box;">
</div>
<!-- Color -->
<div style="margin-bottom: 12px;">
<label style="display: block; margin-bottom: 4px; font-size: 11px; color: #888;">Color</label>
<div id="avatar-color-presets" style="display: flex; gap: 6px; flex-wrap: wrap;">
${COLOR_PRESETS.map(c => `
<div class="avatar-color-swatch" data-color="${c.hex}" title="${c.name}"
style="width: 24px; height: 24px; border-radius: 4px; background: ${c.hex};
cursor: pointer; border: 2px solid ${c.hex === settings.color ? '#fff' : 'transparent'};
transition: border-color 0.15s;">
</div>
`).join('')}
</div>
</div>
<!-- Shape -->
<div style="margin-bottom: 12px;">
<label style="display: block; margin-bottom: 4px; font-size: 11px; color: #888;">Shape</label>
<div style="display: flex; gap: 6px;">
${SHAPE_OPTIONS.map(s => `
<button class="avatar-shape-btn" data-shape="${s.geometry}"
style="flex: 1; padding: 4px 8px; background: ${s.geometry === settings.shape ? '#1a2a4a' : 'transparent'};
border: 1px solid #1a2a4a; color: #ddd; border-radius: 4px; cursor: pointer;
font-family: monospace; font-size: 11px; transition: background 0.15s;">
${s.name}
</button>
`).join('')}
</div>
</div>
<div style="font-size: 10px; color: #555; text-align: center;">
Saved to localStorage
</div>
`;
document.body.appendChild(panel);
// Wire up events
document.getElementById('avatar-name-input').addEventListener('input', (e) => {
settings.name = e.target.value || 'Visitor';
saveSettings();
updateNameTag();
});
document.querySelectorAll('.avatar-color-swatch').forEach(el => {
el.addEventListener('click', () => {
settings.color = el.dataset.color;
saveSettings();
createAvatar();
createSettingsPanel(); // Re-render to update selection
toggleSettingsPanel(true);
});
});
document.querySelectorAll('.avatar-shape-btn').forEach(el => {
el.addEventListener('click', () => {
settings.shape = el.dataset.shape;
saveSettings();
createAvatar();
createSettingsPanel();
toggleSettingsPanel(true);
});
});
}
let panelVisible = false;
function toggleSettingsPanel(forceState) {
const panel = document.getElementById('avatar-settings-panel');
if (!panel) return;
panelVisible = forceState !== undefined ? forceState : !panelVisible;
panel.style.display = panelVisible ? 'block' : 'none';
}
function createToggleButton() {
const btn = document.createElement('button');
btn.id = 'avatar-settings-btn';
btn.textContent = '✦';
btn.title = 'Avatar Settings';
btn.style.cssText = `
position: fixed; bottom: 20px; right: 20px; z-index: 1001;
width: 40px; height: 40px; border-radius: 50%;
background: rgba(10, 15, 40, 0.9); border: 1px solid #4af0c0;
color: #4af0c0; font-size: 18px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s;
`;
btn.addEventListener('mouseenter', () => btn.style.background = 'rgba(74, 240, 192, 0.2)');
btn.addEventListener('mouseleave', () => btn.style.background = 'rgba(10, 15, 40, 0.9)');
btn.addEventListener('click', () => toggleSettingsPanel());
document.body.appendChild(btn);
}
// ═══ Public API ═════════════════════════════════════════════
window.AvatarCustomization = {
init(sceneRef, cameraRef) {
scene = sceneRef;
createAvatar();
createSettingsPanel();
createToggleButton();
console.log(`[Avatar] Initialized — ${settings.name}, ${settings.color}, ${settings.shape}`);
},
update(playerPos) {
if (!avatarMesh) return;
avatarMesh.position.copy(playerPos);
avatarMesh.position.y -= 1.5; // Below camera
avatarMesh.rotation.y += 0.005; // Gentle spin
},
getSettings() {
return { ...settings };
},
setColor(hex) {
settings.color = hex;
saveSettings();
createAvatar();
},
setName(name) {
settings.name = name || 'Visitor';
saveSettings();
updateNameTag();
},
setShape(shape) {
settings.shape = shape;
saveSettings();
createAvatar();
},
};
})();