HOME 을 Figma 「홈」 구성으로 — TODAY'S QUEST · 미니맵·확장 맵(프로토타입 v6.2) · 행성 정보 박스 · 인큐베이터 · 아티스트 소식 · HOT ARCHIVE · 인기 K-INSIDE
This commit is contained in:
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+6
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
Reference in New Issue
Block a user