HOME 을 Figma 「홈」 구성으로 — TODAY'S QUEST · 미니맵·확장 맵(프로토타입 v6.2) · 행성 정보 박스 · 인큐베이터 · 아티스트 소식 · HOT ARCHIVE · 인기 K-INSIDE

This commit is contained in:
Sn-Kinos
2026-10-05 16:29:41 +09:00
parent f84d696c21
commit 54d0b23260
45 changed files with 3448 additions and 1111 deletions
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
File diff suppressed because one or more lines are too long
+680
View File
@@ -0,0 +1,680 @@
<!DOCTYPE html>
<!--
FANUZU 앱 번들판 — 원본: UZU HUD (2).html (발주사 프로토타입 v6.2).
렌더링 코드(three.js 장면 · 셰이더/재질 · 별 · 입자 · HUD 텍스처 · animate)는 한 줄도 바꾸지 않았다.
바꾼 것:
1. CDN 제거 — three.js r128 min 을 같은 폴더 three.min.js 로 번들(MIT 라이선스 주석 유지).
tailwind CDN · font-awesome 제거 — 화면에 남는 클래스(fixed inset-0)만 아래 인라인 CSS 로.
Google Fonts @import → 같은 폴더 JetBrains Mono woff2(@font-face, OFL-JetBrainsMono.txt).
2. 조절 패널(게이지 8개 · D-8)과 상단 헤더(「Fancake」 표기 — 공통 기준 4항)를 숨김(.ui-overlay).
상단 SYSTEM_STATUS 띠(.data-stream)도 숨김 — Figma 343:211127 캡처에 보이지 않는다.
3. 캔버스 touch-action:none — 끌기 · 핀치를 페이지 스크롤 대신 장면이 받게.
4. 원본 스크립트 뒤에 FANUZU 훅 스크립트 추가(원본 전역만 바깥에서 다룸):
window.setGalaxy(json) 데이터 주입 · window.setMode('mini'|'expanded') · window.fanuzuPause(bool) ·
끌기 회전(±180°) · 핀치 줌 · 탭 → FanuzuBridge.postMessage · LIVE 3초 난수 흔들기 차단 ·
글꼴 로드 후 원본 onload 실행.
5. animate() 의 화면 중심 보정 1곳 — 중심 행성 위치를 장면 회전(rotation.y)만큼 돌려 쓴다.
원본은 회전 0 일 때만 중심이 맞아, 끌어서 돌린 뒤 행성을 누르면 초점이 어긋났다(2026-10-05 실기기).
6. mini(93pt) 모드 기본값 — HUD Size 6.0 · Orbit Zoom 0.5 · 카메라 줌 2.2. 원본 값으로는 내 행성 카드가
몇 px 라 읽히지 않아 대표 승인(2026-10-05)으로 키움. 렌더 코드는 그대로이고 게이지 값만 다르다.
-->
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Fancake Cinematic Engine v6.2 - Crystal Clarity</title>
<script src="three.min.js"></script>
<style>
@font-face { font-family: 'JetBrains Mono'; font-weight: 500; src: url('jetbrains-mono-latin-500-normal.woff2') format('woff2'); }
@font-face { font-family: 'JetBrains Mono'; font-weight: 700; src: url('jetbrains-mono-latin-700-normal.woff2') format('woff2'); }
@font-face { font-family: 'JetBrains Mono'; font-weight: 800; src: url('jetbrains-mono-latin-800-normal.woff2') format('woff2'); }
/* FANUZU: tailwind 대체(화면에 남는 클래스만) · 패널/헤더 숨김 · 터치는 장면이 받는다 */
.fixed { position: fixed; } .inset-0 { inset: 0; }
.ui-overlay, .data-stream { display: none !important; }
#canvas-container { touch-action: none; }
body { margin: 0; background-color: #000000; font-family: 'Inter', sans-serif; overflow: hidden; color: #fff; }
canvas { display: block; width: 100%; height: 100%; }
.ui-overlay { position: absolute; inset: 0; pointer-events: none; z-index: 100; }
.interactive { pointer-events: auto; }
/* 선명도 개선: 블러 수치를 줄이고 투명도를 살짝 조절하여 맑은 유리 느낌 구현 */
.glass-panel {
background: rgba(5, 10, 25, 0.75);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.15);
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes scanline { 0% { transform: translateY(-100%); } 100% { transform: translateY(100vh); } }
.scanline { position: absolute; width: 100%; height: 4px; background: linear-gradient(to bottom, transparent, rgba(16, 185, 129, 0.08), transparent); animation: scanline 6s linear infinite; pointer-events: none; z-index: 150; }
input[type=range] { -webkit-appearance: none; background: rgba(255,255,255,0.05); height: 2px; width: 100%; }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; background: #10b981; border-radius: 50%; cursor: pointer; box-shadow: 0 0 15px #10b981; border: 2px solid #fff; }
.fpi-badge { font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: bold; color: #10b981; }
.hide-scrollbar::-webkit-scrollbar { display: none; }
.data-stream { position: absolute; top: 10px; left: 50%; transform: translateX(-50%); font-size: 8px; color: rgba(16, 185, 129, 0.6); font-family: 'JetBrains Mono'; letter-spacing: 2px; text-transform: uppercase; }
.panel-collapsed { transform: translateY(calc(100% - 60px)); }
.rotate-180 { transform: rotate(180deg); }
</style>
</head>
<body>
<div class="scanline"></div>
<div class="data-stream interactive">SYSTEM_STATUS: CRYSTAL_CLARITY_ACTIVE // ENGINE_SYNC: 100% // HUD_DEPTH: -500</div>
<div id="canvas-container" class="fixed inset-0"></div>
<div class="ui-overlay flex flex-col justify-between p-6">
<header class="flex justify-between items-start">
<div class="space-y-1">
<div class="flex items-center gap-2">
<h1 class="text-4xl font-black italic tracking-tighter uppercase leading-none text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-blue-500">Fancake</h1>
<span class="text-[10px] bg-emerald-500/20 text-emerald-400 px-2 py-0.5 rounded border border-emerald-500/30 font-bold">V6.2 CLARITY</span>
</div>
<p class="text-[9px] font-bold text-zinc-500 tracking-[0.5em] uppercase">Deep Space Engine - Zero Mist Mode</p>
</div>
<div class="flex flex-col items-end gap-2 interactive">
<div class="glass-panel px-4 py-2 rounded-full flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-emerald-500 animate-ping shadow-[0_0_10px_#10b981]"></div>
<span class="text-[10px] font-black tracking-widest text-emerald-400 uppercase">Resonance: Ultra High</span>
</div>
</div>
</header>
<section id="main-ui-panel" class="max-w-md mx-auto w-full space-y-4 glass-panel rounded-[40px] p-6 interactive relative">
<button id="ui-toggle-btn" class="absolute -top-3 left-1/2 -translate-x-1/2 w-12 h-6 bg-emerald-500 rounded-full flex items-center justify-center border-2 border-black shadow-lg transition-transform pointer-events-auto">
<i id="toggle-icon" class="fa-solid fa-chevron-down text-black text-[10px] transition-transform"></i>
</button>
<div id="panel-content" class="space-y-4 transition-opacity duration-300">
<div class="grid grid-cols-2 gap-4">
<div class="bg-white/5 p-3 rounded-2xl border border-white/5">
<div class="flex justify-between mb-2">
<span class="text-[9px] font-black text-zinc-500 uppercase">Mass Volume</span>
<span id="scale-val" class="fpi-badge">x1.5</span>
</div>
<!-- Max 30.0 유지 -->
<input id="global-scale-slider" type="range" min="0.5" max="30.0" step="0.1" value="1.5">
</div>
<div class="bg-white/5 p-3 rounded-2xl border border-white/5">
<div class="flex justify-between mb-2">
<span class="text-[9px] font-black text-zinc-500 uppercase">Orbit Exp.</span>
<span id="zoom-val" class="fpi-badge">x1.0</span>
</div>
<input id="orbit-zoom-slider" type="range" min="0.5" max="2.5" step="0.1" value="1.0">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="bg-white/5 p-3 rounded-2xl border border-white/5">
<div class="flex justify-between mb-2">
<span class="text-[9px] font-black text-zinc-500 uppercase">HUD Size</span>
<span id="hud-size-val" class="fpi-badge">x1.0</span>
</div>
<input id="hud-size-slider" type="range" min="0.1" max="2.0" step="0.05" value="1.0">
</div>
<div class="bg-white/5 p-3 rounded-2xl border border-white/5">
<div class="flex justify-between mb-2">
<span class="text-[9px] font-black text-zinc-500 uppercase">HUD Dist.</span>
<span id="hud-dist-val" class="fpi-badge">x1.0</span>
</div>
<!-- -500.0 유지 -->
<input id="hud-dist-slider" type="range" min="-500.0" max="10.0" step="1.0" value="1.0">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="bg-white/5 p-3 rounded-2xl border border-white/5">
<div class="flex justify-between mb-2">
<span class="text-[9px] font-black text-zinc-500 uppercase">Camera Height</span>
<span id="cam-y-val" class="fpi-badge">2000</span>
</div>
<input id="cam-y-slider" type="range" min="0" max="5000" step="10" value="2000">
</div>
<div class="bg-white/5 p-3 rounded-2xl border border-white/5">
<div class="flex justify-between mb-2">
<span class="text-[9px] font-black text-zinc-500 uppercase">View Rotation</span>
<span id="view-rot-val" class="fpi-badge">0°</span>
</div>
<input id="view-rot-slider" type="range" min="-180" max="180" step="1" value="0">
</div>
</div>
<div id="planet-controls" class="space-y-2 max-h-24 overflow-y-auto pr-2 hide-scrollbar"></div>
<div class="flex items-center gap-4 pt-1">
<button id="boost-btn" class="flex-1 h-12 rounded-2xl bg-gradient-to-r from-emerald-500 to-blue-600 flex items-center justify-center gap-3 active:scale-95 transition-all shadow-[0_10px_40px_rgba(16,185,129,0.3)] group">
<i class="fa-solid fa-fire text-lg group-active:scale-150 transition-transform"></i>
<span class="font-black italic tracking-tighter text-md uppercase">Action Flare</span>
</button>
</div>
</div>
</section>
</div>
<script>
const FPI_DATA = [
{ id: 1, name: 'NewJeans', fpi: 3200, color: 0x00F2FF, isMy: true, liveFans: 4820 },
{ id: 2, name: 'BTS', fpi: 2800, color: 0x8b5cf6, isMy: false, liveFans: 12540 },
{ id: 3, name: 'aespa', fpi: 2100, color: 0x10b981, isMy: false, liveFans: 3910 },
{ id: 4, name: 'IVE', fpi: 1700, color: 0xf43f5e, isMy: false, liveFans: 2840 },
{ id: 5, name: 'PLAVE', fpi: 1300, color: 0xf59e0b, isMy: false, liveFans: 5120 }
];
let state = {
boostFactor: 1.0,
planetScale: 1.5,
orbitZoom: 1.0,
hudSize: 1.0,
hudDist: 1.0,
camY: 2000,
viewRot: 0,
uiCollapsed: false,
planets: [...FPI_DATA].sort((a,b) => b.fpi - a.fpi).map((f, i) => ({ ...f, rank: i + 1 }))
};
const scene = new THREE.Scene();
const galaxyGroup = new THREE.Group();
scene.add(galaxyGroup);
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 45000);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.getElementById('canvas-container').appendChild(renderer.domElement);
function createNebula() {
const group = new THREE.Group();
for(let i=0; i<8; i++) {
const geo = new THREE.PlaneGeometry(3000, 3000);
const mat = new THREE.MeshBasicMaterial({
map: generateNebulaTexture(),
transparent: true,
// 뿌연 느낌을 없애기 위해 오퍼시티 대폭 하향 조정
opacity: 0.04,
depthWrite: false,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set((Math.random()-0.5)*2000, (Math.random()-0.5)*500, (Math.random()-0.5)*2000);
mesh.rotation.x = Math.PI / 2;
group.add(mesh);
}
scene.add(group);
return group;
}
function generateNebulaTexture() {
const canvas = document.createElement('canvas');
canvas.width = 256; canvas.height = 256;
const ctx = canvas.getContext('2d');
const grad = ctx.createRadialGradient(128,128,0, 128,128,128);
// 색상 대비를 더 명확하게 하여 뿌연 느낌 제거
grad.addColorStop(0, 'rgba(16, 185, 129, 0.4)');
grad.addColorStop(0.4, 'rgba(59, 130, 246, 0.1)');
grad.addColorStop(0.8, 'transparent');
ctx.fillStyle = grad; ctx.fillRect(0,0,256,256);
return new THREE.CanvasTexture(canvas);
}
const nebula = createNebula();
function createStars() {
const geo = new THREE.BufferGeometry();
const pos = [];
const colors = [];
for(let i=0; i<8000; i++) {
pos.push((Math.random()-0.5)*15000, (Math.random()-0.5)*15000, (Math.random()-0.5)*15000);
colors.push(Math.random() * 0.5 + 0.5, Math.random() * 0.5 + 0.5, 1);
}
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
// 입자를 더 날카롭게 표현
const mat = new THREE.PointsMaterial({ size: 1.2, vertexColors: true, transparent: true, opacity: 0.8 });
const stars = new THREE.Points(geo, mat);
scene.add(stars);
return stars;
}
const stars = createStars();
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); // 조명 대비를 위해 앰비언트 하향
scene.add(ambientLight);
const centerLight = new THREE.PointLight(0xffffff, 4, 25000); // 센터 포인트 라이트 강화
scene.add(centerLight);
const planetObjects = [];
const orbitRings = [];
function generateHUDTexture(data) {
const canvas = document.createElement('canvas');
canvas.width = 512; canvas.height = 256;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, 512, 256);
ctx.strokeStyle = `rgba(${parseInt(data.color.toString(16).substring(0,2),16)}, ${parseInt(data.color.toString(16).substring(2,4),16)}, ${parseInt(data.color.toString(16).substring(4,6),16)}, 0.8)`;
ctx.lineWidth = 4;
ctx.strokeRect(10, 10, 492, 236);
ctx.fillStyle = 'rgba(0, 5, 20, 0.9)'; // HUD 배경을 더 어둡고 단단하게
ctx.fillRect(10, 10, 492, 236);
ctx.font = '800 50px JetBrains Mono';
ctx.fillStyle = '#' + data.color.toString(16).padStart(6, '0');
ctx.fillText(data.name.toUpperCase(), 40, 80);
ctx.font = '700 24px JetBrains Mono';
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.fillText(`RANK #${data.rank}`, 40, 120);
ctx.font = '800 40px JetBrains Mono';
ctx.fillStyle = '#10b981';
ctx.fillText(`FPI: ${data.fpi}`, 40, 180);
ctx.font = '700 24px JetBrains Mono';
ctx.fillStyle = 'rgba(255,255,255,0.9)';
ctx.fillText(`LIVE: ${data.liveFans.toLocaleString()}`, 40, 220);
ctx.fillStyle = '#10b981';
ctx.fillRect(450, 40, 20, 160 * (data.fpi / 5000));
return new THREE.CanvasTexture(canvas);
}
function createOrbitRing(radius, color) {
const geo = new THREE.RingGeometry(radius - 0.5, radius + 0.5, 128); // 링 두께 최소화
const mat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.15, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(geo, mat);
mesh.rotation.x = Math.PI / 2;
galaxyGroup.add(mesh);
return mesh;
}
function createPlanet(data) {
const group = new THREE.Group();
const radius = 15 + (data.fpi / 200);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(radius, 64, 64),
new THREE.MeshStandardMaterial({
color: data.color,
emissive: data.color,
emissiveIntensity: 0.8, // 발광 강화
metalness: 0.9,
roughness: 0.1
})
);
group.add(mesh);
const ringCount = data.rank === 1 ? 3 : (data.rank <= 3 ? 2 : 1);
for (let i = 0; i < ringCount; i++) {
const inner = radius * (1.6 + i * 0.15);
const outer = inner + (radius * 0.03); // 슬림해진 띠
const ringGeo = new THREE.RingGeometry(inner, outer, 128);
const ringMat = new THREE.MeshBasicMaterial({
color: data.color,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.3 / (i + 1), // 띠 명확도 상승
depthWrite: false
});
const ringMesh = new THREE.Mesh(ringGeo, ringMat);
ringMesh.rotation.x = Math.PI / 2;
group.add(ringMesh);
}
const satCount = Math.floor(data.fpi / 10);
const satGeo = new THREE.BufferGeometry();
const satPos = [];
for(let i=0; i<satCount; i++) {
const r = radius * (1.8 + Math.random() * 2.5);
const theta = Math.random() * Math.PI * 2;
const phi = Math.random() * Math.PI;
satPos.push(r * Math.sin(phi) * Math.cos(theta), r * Math.sin(phi) * Math.sin(theta), r * Math.cos(phi));
}
satGeo.setAttribute('position', new THREE.Float32BufferAttribute(satPos, 3));
const satMat = new THREE.PointsMaterial({ color: data.color, size: 2, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending });
const satellites = new THREE.Points(satGeo, satMat);
group.add(satellites);
const auraMat = new THREE.SpriteMaterial({
map: generateGlowTexture(),
color: data.color,
transparent: true,
blending: THREE.AdditiveBlending,
opacity: 0.5
});
const aura = new THREE.Sprite(auraMat);
// 뿌연 느낌을 없애기 위해 Aura 스케일 대폭 축소 (15 -> 6)
aura.scale.set(radius * 6, radius * 6, 1);
group.add(aura);
const hudMat = new THREE.SpriteMaterial({
map: generateHUDTexture(data),
transparent: true,
depthTest: false
});
const hud = new THREE.Sprite(hudMat);
hud.position.y = radius + 5;
hud.scale.set(120, 60, 1);
group.add(hud);
galaxyGroup.add(group);
return {
group, mesh, satellites, aura, hud,
id: data.id, isMy: data.isMy,
baseRadius: radius, curRad: 0, curScale: 1.0
};
}
function generateGlowTexture() {
const canvas = document.createElement('canvas');
canvas.width = 128; canvas.height = 128;
const ctx = canvas.getContext('2d');
const grad = ctx.createRadialGradient(64,64,0, 64,64,64);
// 광륜 중심을 더 선명하게, 외곽을 빠르게 소멸시킴
grad.addColorStop(0, 'rgba(255,255,255,0.8)');
grad.addColorStop(0.2, 'rgba(255,255,255,0.3)');
grad.addColorStop(0.6, 'transparent');
ctx.fillStyle = grad; ctx.fillRect(0,0,128,128);
return new THREE.CanvasTexture(canvas);
}
function animate() {
const time = Date.now() * 0.0005 * state.boostFactor;
let myPos = new THREE.Vector3();
planetObjects.forEach((m, idx) => {
const data = state.planets.find(p => p.id === m.id);
m.curScale = THREE.MathUtils.lerp(m.curScale, state.planetScale, 0.05);
m.group.scale.set(m.curScale, m.curScale, m.curScale);
const orbitSpacing = 450 * state.planetScale * state.orbitZoom;
const targetOrbit = (data.rank - 1) * orbitSpacing;
m.curRad = THREE.MathUtils.lerp(m.curRad, targetOrbit, 0.03);
if(orbitRings[idx]) {
orbitRings[idx].scale.set(m.curRad / 100, m.curRad / 100, 1);
orbitRings[idx].material.opacity = m.curRad === 0 ? 0 : 0.15;
}
const speed = 0.8 / (data.rank + 1);
const x = Math.cos(time * speed + m.id) * m.curRad;
const z = Math.sin(time * speed + m.id) * m.curRad;
m.group.position.set(x, 0, z);
m.mesh.rotation.y += 0.02;
m.satellites.rotation.y -= 0.03;
// 압축된 아우라 스케일 유지
m.aura.scale.set(m.baseRadius * m.curScale * 6, m.baseRadius * m.curScale * 6, 1);
const baseInverseFactor = Math.pow(m.curScale, 0.22);
const hudHeight = 57.5 * baseInverseFactor * state.hudSize;
const proximityOffset = 2 * state.hudDist;
m.hud.position.y = (m.baseRadius * m.curScale) + (hudHeight / 2) + proximityOffset;
m.hud.scale.set(115 * baseInverseFactor * state.hudSize, hudHeight, 1);
if(m.isMy) myPos.copy(m.group.position);
});
// Camera Elevation & Rotation
camera.position.y = THREE.MathUtils.lerp(camera.position.y, state.camY, 0.05);
camera.lookAt(0, 0, 0);
const targetSceneRot = THREE.MathUtils.degToRad(state.viewRot);
galaxyGroup.rotation.y = THREE.MathUtils.lerp(galaxyGroup.rotation.y, targetSceneRot, 0.05);
// [FANUZU] 중심 보정을 장면 회전(rotation.y) 뒤 좌표로 — 원본은 회전 0 일 때만 맞았다.
myPos.applyAxisAngle(new THREE.Vector3(0, 1, 0), galaxyGroup.rotation.y);
galaxyGroup.position.x = THREE.MathUtils.lerp(galaxyGroup.position.x, -myPos.x, 0.08);
galaxyGroup.position.z = THREE.MathUtils.lerp(galaxyGroup.position.z, -myPos.z, 0.08);
stars.rotation.y += 0.0001;
nebula.rotation.y -= 0.00005;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
function buildUI() {
const container = document.getElementById('planet-controls');
container.innerHTML = '';
state.planets.forEach(p => {
const div = document.createElement('div');
div.className = `flex items-center gap-3 bg-white/5 p-3 rounded-2xl border ${p.isMy ? 'border-emerald-500/40 shadow-lg shadow-emerald-500/20' : 'border-white/5'}`;
div.innerHTML = `
<div class="w-24 shrink-0">
<p class="text-[8px] font-black text-zinc-500">RANK ${p.rank}${p.isMy ? ' (MY)' : ''}</p>
<p class="text-[10px] font-black uppercase truncate" style="color: #${p.color.toString(16).padStart(6,'0')}">${p.name}</p>
</div>
<input type="range" min="100" max="5000" value="${p.fpi}" class="flex-1" oninput="updateFPI(${p.id}, this.value)">
<div class="w-12 text-right"><span class="fpi-badge">${p.fpi}</span></div>
`;
container.appendChild(div);
});
}
window.updateFPI = (id, val) => {
const p = state.planets.find(x => x.id === id);
p.fpi = parseInt(val);
state.planets.sort((a,b) => b.fpi - a.fpi).forEach((pl, i) => pl.rank = i+1);
const obj = planetObjects.find(o => o.id === id);
if(obj) obj.hud.material.map = generateHUDTexture(p);
buildUI();
};
const toggleBtn = document.getElementById('ui-toggle-btn');
const mainPanel = document.getElementById('main-ui-panel');
const panelContent = document.getElementById('panel-content');
const toggleIcon = document.getElementById('toggle-icon');
toggleBtn.addEventListener('click', () => {
state.uiCollapsed = !state.uiCollapsed;
if (state.uiCollapsed) {
mainPanel.classList.add('panel-collapsed');
panelContent.style.opacity = '0';
toggleIcon.classList.add('rotate-180');
} else {
mainPanel.classList.remove('panel-collapsed');
panelContent.style.opacity = '1';
toggleIcon.classList.remove('rotate-180');
}
});
document.getElementById('global-scale-slider').oninput = (e) => {
state.planetScale = parseFloat(e.target.value);
document.getElementById('scale-val').innerText = `x${state.planetScale.toFixed(1)}`;
};
document.getElementById('orbit-zoom-slider').oninput = (e) => {
state.orbitZoom = parseFloat(e.target.value);
document.getElementById('zoom-val').innerText = `x${state.orbitZoom.toFixed(1)}`;
};
document.getElementById('hud-size-slider').oninput = (e) => {
state.hudSize = parseFloat(e.target.value);
document.getElementById('hud-size-val').innerText = `x${state.hudSize.toFixed(1)}`;
};
document.getElementById('hud-dist-slider').oninput = (e) => {
state.hudDist = parseFloat(e.target.value);
document.getElementById('hud-dist-val').innerText = `x${state.hudDist.toFixed(1)}`;
};
document.getElementById('cam-y-slider').oninput = (e) => {
state.camY = parseInt(e.target.value);
document.getElementById('cam-y-val').innerText = state.camY;
};
document.getElementById('view-rot-slider').oninput = (e) => {
state.viewRot = parseInt(e.target.value);
document.getElementById('view-rot-val').innerText = `${state.viewRot}°`;
};
const boostBtn = document.getElementById('boost-btn');
boostBtn.onmousedown = () => { state.boostFactor = 10.0; boostBtn.classList.add('bg-emerald-500'); };
boostBtn.onmouseup = () => { state.boostFactor = 1.0; boostBtn.classList.remove('bg-emerald-500'); };
window.onload = () => {
state.planets.forEach((p, idx) => {
planetObjects.push(createPlanet(p));
orbitRings.push(createOrbitRing(100, 0xffffff));
});
camera.position.set(0, 2000, 3500);
camera.lookAt(0, 0, 0);
buildUI();
animate();
setInterval(() => {
state.planets.forEach(p => {
p.liveFans += Math.floor(Math.random() * 21) - 10;
const obj = planetObjects.find(o => o.id === p.id);
if(obj) obj.hud.material.map = generateHUDTexture(p);
});
}, 3000);
};
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
<!-- ===== FANUZU 훅 — 위 원본 스크립트는 그대로 두고, 전역(state · planetObjects · orbitRings ·
galaxyGroup · camera · createPlanet · createOrbitRing)을 바깥에서만 다룬다. ===== -->
<script>
(() => {
const q = new URLSearchParams(location.search);
const bridge = (m) => { if (window.FanuzuBridge) FanuzuBridge.postMessage(JSON.stringify(m)); };
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const num = (k, d) => (q.has(k) && isFinite(+q.get(k)) ? +q.get(k) : d);
// 일시정지 — 화면 밖 · 앱 백그라운드에서 rAF 를 멈춘다(animate 는 그대로).
let paused = false, pending = [];
const raf = window.requestAnimationFrame.bind(window);
// 매 프레임 전에 창 높이가 바뀌었는지 본다(WebView 크기 변경 · 모드 전환 직후 resize 누락 대비).
let lastH = 0;
window.requestAnimationFrame = (cb) => {
if (window.innerHeight !== lastH && typeof applyMode === 'function' && ready) applyMode();
return paused ? (pending.push(cb), 0) : raf(cb);
};
window.fanuzuPause = (p) => {
paused = !!p;
if (!paused) { const run = pending; pending = []; run.forEach((cb) => raf(cb)); }
};
// 데이터 무결성 — 원본의 3초 LIVE 난수 흔들기(setInterval 3000)는 실데이터를 꾸며 내므로 막는다.
const si = window.setInterval.bind(window);
window.setInterval = (fn, ms, ...a) => (ms === 3000 ? 0 : si(fn, ms, ...a));
// 모드 — mini(HOME 93pt · 내 행성 HUD 한 장 · 카메라 기본값) / expanded(Figma 343:211127 축척).
// Figma 는 프로토타입 캡처를 원래 픽셀로 넣어 초점 거리가 약 1650pt 다 → camera.zoom 으로만 맞춘다.
const FIGMA_FOCAL = 1650;
let mode = q.get('mode') || 'expanded';
let userZoom = 1;
let mineKey = null;
const keyOf = (o) => (state.planets.find((p) => p.id === o.id) || {}).key;
function applyMode() {
const mini = mode === 'mini';
planetObjects.forEach((o) => { o.hud.visible = !mini || o.isMy; });
// mini 는 93pt 에서 카드가 읽히게 HUD 를 키우고 궤도를 좁힌다(대표 승인 10/5 — 원본 게이지 범위 밖 허용).
state.hudSize = mini ? num('hud', 6.0) : num('hud', 1.85);
state.orbitZoom = mini ? num('orbit', 0.5) : num('orbit', 1.0);
const base = mini ? 2.2 : FIGMA_FOCAL * 2 * Math.tan(35 * Math.PI / 180) / window.innerHeight;
lastH = window.innerHeight;
camera.zoom = num('zoom', base) * userZoom;
camera.updateProjectionMatrix();
}
function focus(o) { planetObjects.forEach((p) => { p.isMy = p === o; }); applyMode(); }
window.setMode = (m) => {
mode = m; userZoom = 1; state.viewRot = 0;
const mine = planetObjects.find((o) => keyOf(o) === mineKey);
if (mine) focus(mine); else applyMode();
};
window.addEventListener('resize', applyMode);
// 데이터 주입 — [{id,name,rank,fpi,live,color,mine}]. FPI 는 원본 척도(1위 = 3200, 최소 100)로
// 크기·궤도·막대를 정하고, 글자는 실제 값을 쓴다(valueOf / toString).
let ready = false, queued = null;
window.setGalaxy = (json) => {
const list = typeof json === 'string' ? JSON.parse(json) : json;
if (!ready) { queued = list; return; }
const top = Math.max(1, ...list.map((p) => p.fpi));
planetObjects.forEach((o) => galaxyGroup.remove(o.group));
orbitRings.forEach((r) => galaxyGroup.remove(r));
planetObjects.length = 0; orbitRings.length = 0;
state.planets = list.map((p, i) => ({
id: i + 1, key: p.id, name: p.name, rank: p.rank, color: p.color, isMy: !!p.mine, liveFans: p.live,
fpi: { valueOf: () => Math.max(100, Math.round(p.fpi / top * 3200)), toString: () => String(p.fpi) },
}));
mineKey = (list.find((p) => p.mine) || {}).id || null;
state.planets.forEach((p) => {
planetObjects.push(createPlanet(p));
orbitRings.push(createOrbitRing(100, 0xffffff));
});
applyMode();
};
// 제스처 — 끌기 = View Rotation(±180°, 원본 lerp 0.05 · 관성 없음), 두 손가락 = 줌(확장),
// 탭 = mini 는 확장 열기 · expanded 는 HUD → 상세 / 행성 → 중심 전환.
const pts = new Map();
let down = null, moved = false, pinch = null;
const dist = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) || 1; };
addEventListener('pointerdown', (e) => {
pts.set(e.pointerId, e);
if (pts.size === 1) { down = { x: e.clientX, y: e.clientY, rot: state.viewRot }; moved = false; }
if (pts.size === 2) { pinch = { d: dist(), z: userZoom }; moved = true; }
});
addEventListener('pointermove', (e) => {
if (!pts.has(e.pointerId)) return;
pts.set(e.pointerId, e);
if (pts.size === 2 && pinch) {
if (mode !== 'mini') { userZoom = clamp(pinch.z * dist() / pinch.d, 0.5, 3); applyMode(); }
return;
}
if (down && pts.size === 1) {
const dx = e.clientX - down.x;
if (Math.abs(dx) > 6 || Math.abs(e.clientY - down.y) > 6) moved = true;
state.viewRot = clamp(down.rot + dx / window.innerWidth * 180, -180, 180);
}
});
const up = (e) => {
pts.delete(e.pointerId);
if (pts.size) return;
if (down && !moved && e.type === 'pointerup') tap(e);
down = null; pinch = null;
};
addEventListener('pointerup', up);
addEventListener('pointercancel', up);
const ray = new THREE.Raycaster();
const v = new THREE.Vector3();
function tap(e) {
if (mode === 'mini') return bridge({ type: 'open' });
ray.setFromCamera(new THREE.Vector2(e.clientX / innerWidth * 2 - 1, -(e.clientY / innerHeight) * 2 + 1), camera);
const hud = ray.intersectObjects(planetObjects.filter((o) => o.hud.visible).map((o) => o.hud), false)[0];
if (hud) return bridge({ type: 'hud', id: keyOf(planetObjects.find((o) => o.hud === hud.object)) });
// 작은 행성도 손끝으로 — 화면 거리 28px 안 가장 가까운 행성.
let best = null, bestD = 28;
planetObjects.forEach((o) => {
v.setFromMatrixPosition(o.group.matrixWorld).project(camera);
const d = Math.hypot((v.x + 1) / 2 * innerWidth - e.clientX, (1 - v.y) / 2 * innerHeight - e.clientY);
if (v.z < 1 && d < bestD) { best = o; bestD = d; }
});
if (best) { focus(best); bridge({ type: 'planet', id: keyOf(best) }); }
}
// 시작 — 글꼴을 먼저 받아 HUD 텍스처가 JetBrains Mono 로 그려지게 한 뒤 원본 onload.
// 앱 안(FanuzuBridge 있음)에서는 원본 표본 5행성 대신 앱 데이터를 기다린다.
const origLoad = window.onload;
window.onload = async () => {
try { await Promise.all(['500', '700', '800'].map((w) => document.fonts.load(`${w} 50px "JetBrains Mono"`))); } catch (_) {}
origLoad();
ready = true;
// ?data=<json> — 브라우저 캡처 · 점검용 주입.
if (q.has('data')) queued = JSON.parse(q.get('data'));
if (queued || window.FanuzuBridge || q.has('wait')) window.setGalaxy(queued || []);
else applyMode();
bridge({ type: 'ready' });
};
})();
</script>
</body>
</html>