<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TOKYO SICKS / SASORI STICKER POLE</title>
<!-- Three.js Core -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<!-- OrbitControls -->
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<style>
body {
margin: 0;
overflow: hidden;
background-color: #0a0a0f;
font-family: 'Inter', sans-serif;
}
#canvas-container {
width: 100vw;
height: 100vh;
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
.ui-panel {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 10;
width: 340px;
max-height: calc(100vh - 2rem);
overflow-y: auto;
background: rgba(20, 20, 25, 0.85);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 10px 40px rgba(0,0,0,0.9);
}
/* Loading Overlay */
#loading-overlay {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(10, 10, 15, 0.9);
z-index: 20;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: #fff;
transition: opacity 0.4s ease;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(255, 100, 0, 0.2);
border-top: 4px solid #ff6400;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
/* Custom Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: rgba(0,0,0,0.3); }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.4); }
*{box-sizing:border-box}button,input,textarea{font:inherit}body{color:#e5e7eb}svg{width:20px;height:20px;flex:none}h2{margin:0;font-size:17px;display:flex;align-items:center;gap:8px;color:white}p{font-size:10px;color:#9ca3af;margin:5px 0 0}.ui-panel{border-radius:12px;max-width:calc(100vw - 32px)}#panel-header{padding:16px;background:#1f2937;cursor:pointer;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #374151}#panel-content{padding:20px;overflow:hidden;transition:max-height .3s,padding .3s,opacity .3s}#panel-content>div{margin-bottom:20px}label{font-size:11px;letter-spacing:1px;font-weight:700;color:#9ca3af;display:flex;justify-content:space-between;margin-bottom:8px}label span:last-child{color:#fb923c}textarea{width:100%;padding:8px 12px;background:#111827;border:1px solid #374151;border-radius:6px;color:#d1d5db;font-size:12px;resize:none}input[type=range]{width:100%;accent-color:#f97316}#generate-btn{width:100%;padding:12px;background:#ea580c;border:1px solid #fb923c;border-radius:6px;color:white;cursor:pointer;font-weight:700;letter-spacing:1px}#generate-btn:disabled{opacity:.5;cursor:wait}#loading-text{padding:20px;text-align:center;font-weight:700;letter-spacing:2px;color:#fb923c}#pole-status{position:absolute;bottom:14px;left:14px;z-index:5;color:#a9a4a1;font:11px monospace;max-width:70vw;pointer-events:none}canvas{display:block;touch-action:none}@media(max-width:600px){.ui-panel{width:280px;top:12px;right:12px;max-height:calc(100vh - 24px)}#panel-header{padding:12px}h2{font-size:14px}#panel-content{padding:16px}}
html,body{width:100%;height:100%;min-height:100%;padding:0}#pole-bg-a,#pole-bg-b{position:absolute;inset:0;background-size:cover;background-position:center;background-repeat:no-repeat;opacity:0;transition:opacity 2s ease} @media(prefers-reduced-motion:reduce){#pole-bg-a,#pole-bg-b{transition:none}} #pole-backdrop{position:fixed;inset:0;z-index:0;background-color:#0a0a0f;background-size:cover;background-position:center;background-repeat:no-repeat;pointer-events:none}
#pole-backdrop{inset:-8%;transform-origin:center;overflow:hidden}
@media(max-width:700px),(pointer:coarse){#pole-backdrop{inset:-3%}}
.archive-entry{position:absolute;z-index:8;bottom:max(28px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);width:max-content;max-width:calc(100% - 32px)}
.archive-enter{display:flex;align-items:center;justify-content:center;gap:clamp(10px,2vw,24px);min-height:54px;padding:14px clamp(14px,2.6vw,32px);box-sizing:border-box;max-width:100%;border:1px solid rgba(255,255,255,.4);border-radius:0;background:rgba(9,9,12,.82);color:#f4f1eb;cursor:pointer;font-family:Helvetica,Arial,sans-serif;transition:background .25s,border-color .25s,box-shadow .25s;-webkit-tap-highlight-color:transparent}
.archive-enter-main{font-size:clamp(11px,1.3vw,15px);font-weight:700;letter-spacing:.16em;white-space:nowrap}
.archive-enter-fragment{font-size:clamp(10px,1vw,12px);font-weight:500;letter-spacing:.06em;white-space:nowrap;color:#bcb9b5}
.archive-enter:focus-visible{outline:2px solid #fff;outline-offset:5px}
.archive-enter:focus-visible .archive-enter-self{animation:archive-self-tremor .9s steps(1,end) infinite}
@media(hover:hover){.archive-enter:hover{background:rgba(9,9,12,.95);border-color:rgba(255,255,255,.8);box-shadow:0 0 20px rgba(255,255,255,.09)}.archive-enter:hover .archive-enter-self{animation:archive-self-tremor .9s steps(1,end) infinite}}
@keyframes archive-self-tremor{0%,100%,40%,60%{transform:translate(0,0);opacity:1}42%,57%{transform:translate(1px,0);opacity:.65}45%,54%{transform:translate(-1px,1px);opacity:.9}}
@media(max-width:400px){.archive-enter{gap:8px;padding:14px 12px}.archive-enter-main{letter-spacing:.08em}}
@media(prefers-reduced-motion:reduce){.archive-enter{transition:none}.archive-enter .archive-enter-self{animation:none!important}}
#meiko-stage{position:absolute;inset:0;z-index:30;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,0);opacity:0;transition:opacity 1s ease,background 2.6s ease}
#meiko-stage.is-visible{opacity:1;background:rgba(0,0,0,.5)}
#meiko-stage.is-full{background:#000}
#meiko-screen{width:76%;height:76%;background:#000;overflow:hidden;transform:scale(.96);transition:width 2.6s cubic-bezier(.22,.65,.2,1),height 2.6s cubic-bezier(.22,.65,.2,1),transform 2.6s ease}
#meiko-stage.is-full #meiko-screen{width:100%;height:100%;transform:scale(1)}
#meiko-video{display:block;width:100%;height:100%;object-fit:contain;background:#000}
#meiko-message{position:absolute;bottom:24px;left:5%;width:90%;text-align:center;color:#fff;font:12px Helvetica,Arial,sans-serif;letter-spacing:.08em}
#meiko-retry{display:none;margin:12px auto;padding:12px 24px;background:#111;color:#fff;border:1px solid #999;cursor:pointer}
#meiko-blackout{position:absolute;inset:0;z-index:40;background:#000;opacity:0;pointer-events:none;transition:opacity 1.2s ease}
#meiko-blackout.is-visible{opacity:1;pointer-events:auto}
@media(max-width:700px){#meiko-screen{width:90%;height:66%}}
@media(prefers-reduced-motion:reduce){#meiko-stage,#meiko-screen,#meiko-blackout{transition-duration:.15s}}
</style>
</head>
<body>
<div id="pole-backdrop" aria-hidden="true"><div id="pole-bg-a"></div><div id="pole-bg-b"></div></div>
<div id="loading-overlay">
<div class="spinner"></div>
<div id="loading-text" class="text-lg font-bold tracking-widest text-orange-400">INITIALIZING...</div>
</div>
<div id="canvas-container"></div><div id="pole-status" role="status"></div>
<!-- Entrance display; video/navigation will be connected in the next step. -->
<div class="archive-entry">
<button type="button" id="archive-enter" class="archive-enter" aria-label="いいえ、ENTER THE ARCHIVE、私は">
<span class="archive-enter-fragment">【いいえ】</span>
<span class="archive-enter-main">ENTER THE ARCHIVE</span>
<span class="archive-enter-fragment archive-enter-self">【私は】</span>
</button>
</div>
<div id="meiko-stage" aria-label="芽衣子の映像" aria-hidden="true">
<div id="meiko-screen"><video id="meiko-video" playsinline preload="none"></video></div>
<div id="meiko-message" role="status"><span id="meiko-status"></span><button type="button" id="meiko-retry">PLAY VIDEO</button></div>
</div>
<div id="meiko-blackout" aria-hidden="true"></div>
<div class="ui-panel rounded-xl text-gray-200 flex flex-col transition-all duration-300" id="ui-panel">
<div id="panel-header" role="button" tabindex="0" aria-label="Toggle pole settings" class="p-4 cursor-pointer flex justify-between items-center bg-gray-800 hover:bg-gray-700 rounded-t-xl transition-colors border-b border-gray-700 shadow-md">
<h2 class="text-lg font-black flex items-center gap-2 text-white m-0 tracking-wide">
<svg class="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"></path></svg>
POLE SETTINGS
</h2>
<svg id="toggle-icon" class="w-5 h-5 text-gray-400 transform transition-transform duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path></svg>
</div>
<div id="panel-content" class="p-5 transition-all duration-300 overflow-hidden" style="max-height: 1200px; opacity: 1;">
<div class="mb-4">
<label class="block text-xs font-bold mb-2 text-gray-400 uppercase tracking-wider">Image URLs (incl. Base64)</label>
<textarea id="urls-input" rows="4" class="w-full bg-gray-900 border border-gray-700 rounded-md px-3 py-2 text-xs text-gray-300 focus:outline-none focus:border-orange-500 transition resize-none"></textarea>
<p class="text-[10px] text-gray-500 mt-1">Paste your image URLs or huge Base64 strings here.</p>
</div>
<div class="mb-5">
<label class="block text-xs font-bold mb-2 text-gray-400 uppercase tracking-wider flex justify-between">
<span>Base Size (px)</span>
<span id="size-display" class="text-orange-400">300</span>
</label>
<input type="range" id="size-slider" min="100" max="1000" value="300" class="w-full accent-orange-500 h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer">
<p class="text-[10px] text-gray-500 mt-1">Auto-scales huge images down to this target width.</p>
</div>
<div class="mb-5">
<label class="block text-xs font-bold mb-2 text-gray-400 uppercase tracking-wider flex justify-between">
<span>Sticker Count</span>
<span id="count-display" class="text-orange-400">100</span>
</label>
<input type="range" id="count-slider" min="10" max="150" value="100" class="w-full accent-orange-500 h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer">
</div>
<div class="mb-5">
<label class="block text-xs font-bold mb-2 text-gray-400 uppercase tracking-wider flex justify-between">
<span>Tear / Weathering Level</span>
<span id="tear-display" class="text-orange-400">0.3</span>
</label>
<input type="range" id="tear-slider" min="0" max="1" step="0.01" value="0.3" class="w-full accent-orange-500 h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer">
</div>
<div class="mb-6">
<label class="block text-xs font-bold mb-2 text-gray-400 uppercase tracking-wider flex justify-between">
<span>Rotation Speed</span>
<span id="speed-display" class="text-orange-400">0.005</span>
</label>
<input type="range" id="speed-slider" min="0" max="0.05" step="0.001" value="0.005" class="w-full accent-orange-500 h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer">
</div>
<button id="generate-btn" class="w-full bg-orange-600 hover:bg-orange-500 text-white font-bold py-3 px-4 rounded-md transition-all shadow-[0_4px_15px_rgba(255,100,0,0.4)] active:scale-95 text-sm uppercase tracking-widest border border-orange-400/30">
Generate Texture
</button>
</div>
</div>
<script>
const defaultUrls = [];
document.getElementById('urls-input').value = defaultUrls.join('\n');
let scene, camera, renderer, poleMesh, orbitControls;
let rotationSpeed = 0.005;
let isGenerating = false;
const mobile = matchMedia('(max-width: 700px)').matches || matchMedia('(pointer: coarse)').matches;
const TEX_WIDTH = mobile ? 1024 : 2048;
const TEX_HEIGHT = TEX_WIDTH / 2;
const textureScale = TEX_WIDTH / 4096;
let active = false, raf = 0, previousTime = 0, dead = false;
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
function initThreeJS() {
const container = document.getElementById('canvas-container');
scene = new THREE.Scene();
scene.background = null;
scene.fog = new THREE.FogExp2(0x0a0a0f, 0.02);
camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 0, 18);
renderer = new THREE.WebGLRenderer({ antialias: !mobile, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, mobile ? 1 : 1.5));
renderer.shadowMap.enabled = !mobile;
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
renderer.setClearColor(0x000000, 0);
container.appendChild(renderer.domElement);
orbitControls = new THREE.OrbitControls(camera, renderer.domElement);
orbitControls.enableZoom = false;
orbitControls.enablePan = false;
orbitControls.enableRotate = true;
orbitControls.enableKeys = false;
orbitControls.enableDamping = true;
orbitControls.dampingFactor = 0.05;
orbitControls.minDistance = 6;
orbitControls.maxDistance = 40;
if (mobile) camera.position.z = 25;
orbitControls.minPolarAngle = Math.PI / 2;
orbitControls.maxPolarAngle = Math.PI / 2;
// Cinematic Lighting Setup
const ambientLight = new THREE.AmbientLight(0xffffff, 0.15);
scene.add(ambientLight);
// Key light (Warm Orange)
const pointLight1 = new THREE.PointLight(0xffaa55, 2.0, 50);
pointLight1.position.set(-6, 4, 8);
scene.add(pointLight1);
// Fill light (Cool Blue) for cyberpunk/street vibe
const pointLight2 = new THREE.PointLight(0x55aaff, 1.5, 50);
pointLight2.position.set(8, -2, -6);
scene.add(pointLight2);
// Directional for subtle shadows
const dirLight = new THREE.DirectionalLight(0xffedd9, 1.0);
dirLight.position.set(10, 15, 10);
dirLight.castShadow = !mobile;
dirLight.shadow.mapSize.width = 1024;
dirLight.shadow.mapSize.height = 1024;
scene.add(dirLight);
// Create geometry
const geometry = new THREE.CylinderGeometry(3, 3, 16, mobile ? 40 : 64);
const material = new THREE.MeshStandardMaterial({
color: 0x888888,
metalness: 0.5,
roughness: 0.7
});
poleMesh = new THREE.Mesh(geometry, material);
poleMesh.castShadow = true;
poleMesh.receiveShadow = true;
scene.add(poleMesh);
window.addEventListener('resize', onWindowResize, false);
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
const timer = setTimeout(() => { img.onload = img.onerror = null; img.src = ''; reject(new Error('Image timeout')); }, 12000);
img.crossOrigin = 'anonymous';
img.onload = () => { clearTimeout(timer); resolve(img); };
img.onerror = () => { clearTimeout(timer); reject(new Error('Image failed')); };
img.src = url;
});
}
async function generateTexture() {
if (isGenerating) return;
isGenerating = true;
document.getElementById("generate-btn").disabled = true;
const loadingOverlay = document.getElementById('loading-overlay');
const loadingText = document.getElementById('loading-text');
loadingOverlay.style.display = 'flex';
loadingOverlay.style.opacity = '1';
loadingText.textContent = "PROCESSING IMAGES...";
try {
const canvas = document.createElement('canvas');
canvas.width = TEX_WIDTH;
canvas.height = TEX_HEIGHT;
const ctx = canvas.getContext('2d');
// --- 1. Draw Rust Base ---
ctx.fillStyle = '#2c1e16';
ctx.fillRect(0, 0, TEX_WIDTH, TEX_HEIGHT);
for (let i = 0; i < (mobile ? 5000 : 12000); i++) {
ctx.fillStyle = Math.random() > 0.5 ? 'rgba(0,0,0,0.2)' : 'rgba(139,69,19,0.2)';
const size = Math.random() * 5 * textureScale;
ctx.fillRect(Math.random() * TEX_WIDTH, Math.random() * TEX_HEIGHT, size, size);
}
for (let i = 0; i < 200; i++) {
const r = 80 + Math.random() * 60;
const g = 30 + Math.random() * 30;
const b = 10 + Math.random() * 20;
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${Math.random() * 0.5})`;
ctx.beginPath();
ctx.arc(Math.random() * TEX_WIDTH, Math.random() * TEX_HEIGHT, Math.random() * 60 * textureScale, 0, Math.PI * 2);
ctx.fill();
}
// --- 2. Load Images ---
const urlsText = document.getElementById('urls-input').value;
let urls = urlsText.split('\n').map(u => u.trim()).filter(u => u.length > 0);
if (urls.length === 0) urls = defaultUrls;
const loadedImages = [];
// Four concurrent decoders; blank configuration still produces a rusty pole.
let next = 0, failed = 0;
await Promise.all(Array.from({length: Math.min(4, urls.length)}, async () => {
while (next < urls.length) {
const url = urls[next++];
try { loadedImages.push(await loadImage(url)); } catch (_) { failed++; }
}
}));
if (dead) return;
document.getElementById('pole-status').textContent = failed ? `${failed} image(s) could not be loaded` : (loadedImages.length ? '' : 'STICKERS NOT REGISTERED');
// --- 3. Draw Stickers with Auto-Scaling ---
loadingText.textContent = "APPLYING STICKER BOMB...";
const count = parseInt(document.getElementById('count-slider').value);
const tearLevel = parseFloat(document.getElementById('tear-slider').value);
// Get Target Size from UI (CRUCIAL FIX FOR BASE64 / HIGH-RES)
const targetSize = (parseInt(document.getElementById('size-slider').value) || 300) * textureScale;
const drawSticker = (img, x, y, angle, scale) => {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(scale, scale); // Scale context based on calculated normalized scale
const w = img.width;
const h = img.height;
const hw = w / 2;
const hh = h / 2;
const maxInset = Math.min(w, h) * (tearLevel * 0.5); // Adjust intensity
const c1 = { x: -hw + Math.random()*maxInset, y: -hh + Math.random()*maxInset };
const c2 = { x: hw - Math.random()*maxInset, y: -hh + Math.random()*maxInset };
const c3 = { x: hw - Math.random()*maxInset, y: hh - Math.random()*maxInset };
const c4 = { x: -hw + Math.random()*maxInset, y: hh - Math.random()*maxInset };
const drawJaggedLine = (p1, p2) => {
const steps = Math.floor(8 + Math.random() * 12);
const dx = (p2.x - p1.x) / steps;
const dy = (p2.y - p1.y) / steps;
const length = Math.hypot(p2.x - p1.x, p2.y - p1.y);
const perpX = -dy / length;
const perpY = dx / length;
for(let i = 1; i <= steps; i++) {
const nx = p1.x + dx * i;
const ny = p1.y + dy * i;
const noise = (Math.random() - 0.5) * length * tearLevel * 0.3;
if(i === steps) ctx.lineTo(p2.x, p2.y);
else ctx.lineTo(nx + perpX * noise, ny + perpY * noise);
}
};
ctx.beginPath();
ctx.moveTo(c1.x, c1.y);
drawJaggedLine(c1, c2);
drawJaggedLine(c2, c3);
drawJaggedLine(c3, c4);
drawJaggedLine(c4, c1);
ctx.closePath();
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)';
ctx.shadowBlur = 15;
ctx.shadowOffsetX = 4;
ctx.shadowOffsetY = 8;
ctx.fillStyle = '#f5f5f5';
ctx.fill();
ctx.shadowColor = 'transparent';
ctx.save();
ctx.clip();
ctx.drawImage(img, -hw, -hh, w, h);
if (tearLevel > 0) {
const numScratches = Math.floor(Math.random() * 20 * tearLevel);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
for(let i = 0; i < numScratches; i++) {
ctx.lineWidth = Math.random() * 4;
ctx.beginPath();
const sx = (Math.random() - 0.5) * w;
const sy = (Math.random() - 0.5) * h;
ctx.moveTo(sx, sy);
ctx.lineTo(sx + (Math.random() - 0.5) * w * 0.5, sy + (Math.random() - 0.5) * h * 0.5);
ctx.stroke();
}
}
ctx.restore();
if (tearLevel > 0) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
ctx.lineWidth = 2 + Math.random() * 4 * tearLevel;
ctx.stroke();
}
ctx.restore();
};
for (let i = 0; i < (loadedImages.length ? count : 0); i++) {
const img = loadedImages[Math.floor(Math.random() * loadedImages.length)];
const x = Math.random() * TEX_WIDTH;
const y = Math.random() * TEX_HEIGHT;
const angle = (Math.random() * Math.PI) - (Math.PI / 2);
// --- SCALING FIX ---
// Calculate how much we need to scale the image to reach the "Target Size"
const baseScale = targetSize / img.width;
// Add some slight random variation (e.g. 70% to 130% of target size)
const finalScale = baseScale * (0.7 + Math.random() * 0.6);
drawSticker(img, x, y, angle, finalScale);
// Seamless wrapping logic
const imgVisualRadius = Math.max(img.width, img.height) * finalScale;
if (x - imgVisualRadius < 0) {
drawSticker(img, x + TEX_WIDTH, y, angle, finalScale);
} else if (x + imgVisualRadius > TEX_WIDTH) {
drawSticker(img, x - TEX_WIDTH, y, angle, finalScale);
}
}
// --- 4. Apply Texture ---
loadingText.textContent = "UPDATING 3D MODEL...";
const canvasTexture = new THREE.CanvasTexture(canvas);
canvasTexture.anisotropy = Math.min(mobile ? 2 : 4, renderer.capabilities.getMaxAnisotropy());
canvasTexture.wrapS = THREE.RepeatWrapping;
canvasTexture.wrapT = THREE.ClampToEdgeWrapping;
canvasTexture.encoding = THREE.sRGBEncoding;
if (poleMesh.material.map) poleMesh.material.map.dispose();
poleMesh.material.map = canvasTexture;
poleMesh.material.roughness = 0.85;
poleMesh.material.metalness = 0.4;
poleMesh.material.needsUpdate = true;
} catch (error) {
document.getElementById('pole-status').textContent = 'Texture creation failed. Please check image sources.';
console.warn('Sticker texture:', error);
} finally {
isGenerating = false;
document.getElementById('generate-btn').disabled = false;
loadingOverlay.style.opacity = '0';
setTimeout(() => loadingOverlay.style.display = 'none', 400);
}
}
let backgroundElapsed = 0;
const movingBackdrop = document.getElementById('pole-backdrop');
function moveBackground(dt) {
if (reducedMotion || !movingBackdrop) return;
backgroundElapsed += dt;
const phase = (1 - Math.cos(backgroundElapsed * Math.PI / (mobile ? 20 : 16))) / 2;
movingBackdrop.style.transform = mobile
? 'scale(' + (1 + phase * .065) + ')'
: 'translate(' + (-1 + phase * 2) + '%, ' + (.6 - phase * 1.2) + '%) rotate(' + (-.9 + phase * 1.8) + 'deg) skewX(' + (-.45 + phase * .9) + 'deg) scale(' + (1 + phase * .12) + ')';
}
function animate(time) {
raf = 0;
if (!active || document.hidden || dead || enteringArchive) return;
raf = requestAnimationFrame(animate);
if (mobile && previousTime && time - previousTime < 32) return;
const dt = previousTime ? Math.min((time - previousTime) / 1000, .05) : 1 / 60;
previousTime = time;
moveBackground(dt);
if (poleMesh && !reducedMotion) poleMesh.rotation.y += rotationSpeed * dt * 60;
orbitControls.update();
renderer.render(scene, camera);
}
let backgroundConfig = null, backgroundTimer = 0, backgroundToken = 0;
let backgroundCurrent = '', backgroundPending = '', backgroundLayer = 0;
const japanClock = new Intl.DateTimeFormat('en-GB', {timeZone:'Asia/Tokyo', hour:'2-digit', hourCycle:'h23'});
function backgroundSlot(hour) {
return hour < 5 ? 'midnight' : hour < 9 ? 'morning' : hour < 16 ? 'day' : hour < 19 ? 'evening' : 'night';
}
function checkBackground() {
clearTimeout(backgroundTimer); backgroundTimer = 0;
if (!backgroundConfig || dead || !active || document.hidden || enteringArchive) return;
const hour = Number(japanClock.format(new Date()));
const url = (backgroundConfig.backgrounds || {})[backgroundSlot(hour)] || backgroundConfig.backgroundImage;
if (url && url !== backgroundCurrent && url !== backgroundPending) {
const token = ++backgroundToken;
backgroundPending = url;
const picture = new Image();
picture.onload = () => {
if (dead || token !== backgroundToken) return;
const layers = [document.getElementById('pole-bg-a'), document.getElementById('pole-bg-b')];
const next = backgroundCurrent ? 1 - backgroundLayer : backgroundLayer;
const layer = layers[next];
layer.style.transition = 'none';
layer.style.opacity = '0';
layer.style.backgroundImage = 'url(' + JSON.stringify(url) + ')';
layer.style.zIndex = '2';
layers[1-next].style.zIndex = '1';
void layer.offsetWidth;
layer.style.transition = reducedMotion || !backgroundCurrent ? 'none' : 'opacity 2s ease';
layer.style.opacity = '1';
backgroundLayer = next; backgroundCurrent = url; backgroundPending = '';
};
picture.onerror = () => {
if (token !== backgroundToken) return;
backgroundPending = '';
console.warn('Library background could not be loaded');
};
picture.src = url;
}
backgroundTimer = setTimeout(checkBackground, 60000 - Date.now() % 60000 + 50);
}
function setActive(value) {
active = value;
checkBackground();
cancelAnimationFrame(raf); raf = 0; previousTime = 0;
if (active && !document.hidden && renderer && !dead && !enteringArchive) raf = requestAnimationFrame(animate);
}
document.addEventListener('visibilitychange', () => setActive(active));
window.addEventListener('message', event => {
if (event.source !== parent || !event.data || event.data.type !== 'sasori-pole-visibility') return;
setActive(!!event.data.active);
});
window.addEventListener('pagehide', () => {
dead = true; ++backgroundToken; setActive(false);
if (orbitControls) orbitControls.dispose();
if (poleMesh) { if (poleMesh.material.map) poleMesh.material.map.dispose(); poleMesh.material.dispose(); poleMesh.geometry.dispose(); }
if (scene) scene.traverse(object => { if (object.shadow) object.shadow.dispose(); });
if (renderer) { renderer.dispose(); renderer.forceContextLoss(); }
});
// Safe UI Event Bindings
const bindEvent = (id, event, callback) => {
const el = document.getElementById(id);
if (el) el.addEventListener(event, callback);
};
bindEvent('panel-header', 'keydown', e => { if(e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.currentTarget.click(); } });
bindEvent('panel-header', 'click', () => {
const content = document.getElementById('panel-content');
const icon = document.getElementById('toggle-icon');
if (!content || !icon) return;
if (content.style.maxHeight === '0px' || content.style.maxHeight === '') {
content.style.maxHeight = "1200px";
content.style.paddingTop = "1.25rem";
content.style.paddingBottom = "1.25rem";
content.style.opacity = "1";
icon.style.transform = "rotate(0deg)";
} else {
content.style.maxHeight = "0px";
content.style.paddingTop = "0";
content.style.paddingBottom = "0";
content.style.opacity = "0";
icon.style.transform = "rotate(180deg)";
}
});
bindEvent('size-slider', 'input', (e) => {
const display = document.getElementById('size-display');
if(display) display.textContent = e.target.value;
});
bindEvent('count-slider', 'input', (e) => {
const display = document.getElementById('count-display');
if(display) display.textContent = e.target.value;
});
bindEvent('tear-slider', 'input', (e) => {
const display = document.getElementById('tear-display');
if(display) display.textContent = e.target.value;
});
bindEvent('speed-slider', 'input', (e) => {
rotationSpeed = parseFloat(e.target.value);
const display = document.getElementById('speed-display');
if(display) display.textContent = rotationSpeed.toFixed(3);
});
bindEvent('generate-btn', 'click', () => {
generateTexture();
});
let enteringArchive = false, entranceFinished = false, expandTimer = 0, exitTimer = 0;
function setupEntrance(config) {
const button = document.getElementById('archive-enter');
const video = document.getElementById('meiko-video');
const stage = document.getElementById('meiko-stage');
const retry = document.getElementById('meiko-retry');
const status = document.getElementById('meiko-status');
const blackout = document.getElementById('meiko-blackout');
const play = () => {
retry.style.display = 'none'; status.textContent = 'LOADING…';
const promise = video.play();
if (promise && promise.catch) promise.catch(() => {
if(dead || entranceFinished) return;
status.textContent = '映像を再生するには PLAY VIDEO を押してください。';
retry.style.display = 'block';
});
};
button.addEventListener('click', () => {
if(enteringArchive || dead || !config.videoUrl) return;
enteringArchive = true; setActive(false);
button.disabled = true; button.parentElement.style.display = 'none';
stage.style.display = 'flex'; stage.setAttribute('aria-hidden','false');
void stage.offsetWidth; stage.classList.add('is-visible');
video.src = config.videoUrl;
play();
});
retry.addEventListener('click', play);
video.addEventListener('playing', () => {
status.textContent = ''; retry.style.display = 'none';
if (!stage.classList.contains('is-full') && !expandTimer) expandTimer = setTimeout(() => {
expandTimer = 0; if(!dead && !entranceFinished) stage.classList.add('is-full');
}, 1400);
});
video.addEventListener('error', () => {
if(dead || entranceFinished) return;
status.textContent = '映像を読み込めませんでした。通信を確認して再試行してください。';
retry.style.display = 'block';
});
video.addEventListener('ended', () => {
if(entranceFinished || dead) return;
entranceFinished = true; clearTimeout(expandTimer);
blackout.classList.add('is-visible');
exitTimer = setTimeout(() => {
if(dead) return;
video.pause(); video.removeAttribute('src'); video.load();
parent.postMessage({type:'sasori-archive-entered'}, '*');
}, reducedMotion ? 200 : 1250);
});
window.addEventListener('pagehide', () => {
clearTimeout(expandTimer);clearTimeout(exitTimer);video.pause();video.removeAttribute('src');video.load();
});
}
function start(config) {
try {
if (!window.THREE || !THREE.OrbitControls) throw new Error('3D library unavailable');
document.getElementById('urls-input').value = (Array.isArray(config.stickers) ? config.stickers : []).filter(value => typeof value === 'string').join('\n');
const applySetting = (id, value, fallback, min, max) => {
const number = typeof value === 'number' && Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
document.getElementById(id + '-slider').value = number;
document.getElementById(id + '-display').textContent = number;
return number;
};
applySetting('size', config.baseSize, 300, 100, 1000);
applySetting('count', config.stickerCount, 100, 10, 150);
applySetting('tear', config.weathering, .3, 0, 1);
rotationSpeed = applySetting('speed', config.rotationSpeed, .005, 0, .05);
backgroundConfig = config;
setupEntrance(config);
initThreeJS();
if (mobile || config.showSettings === false) document.getElementById('panel-header').click();
if (config.showSettings === false) document.getElementById('ui-panel').style.display = 'none';
generateTexture(); setActive(!!config.active);
} catch (error) {
document.querySelector('.spinner').style.display = 'none';
document.getElementById('loading-text').textContent = '3Dを開始できませんでした。通信・WebGLの利用状況を確認してください。';
console.warn('Sasori pole:', error);
}
}
function receiveConfig(event) {
if (event.source !== parent || !event.data || event.data.type !== 'sasori-pole-config') return;
window.removeEventListener('message', receiveConfig);
start(event.data.config || {});
}
window.addEventListener('message', receiveConfig);
parent.postMessage({type:'sasori-pole-ready'}, '*');
</script>
</body>
</html>