489 lines
51 KiB
HTML
489 lines
51 KiB
HTML
<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
|
<title>神域方块 · MYTHOS CRAFT</title>
|
|
<style>
|
|
:root{--gold:#f6d78b;--gold2:#b98743;--ink:#071317;--cyan:#71f4dc;--paper:#e9e0c5;--danger:#ff796c}
|
|
*{box-sizing:border-box}html,body{width:100%;height:100%;margin:0;overflow:hidden;background:#061116;color:var(--paper);font-family:"Noto Sans SC","Microsoft YaHei",system-ui,sans-serif}
|
|
body{user-select:none}canvas{display:block}#game{position:fixed;inset:0}
|
|
#game:after{content:"";position:absolute;inset:0;pointer-events:none;background:radial-gradient(circle at 50% 44%,transparent 48%,rgba(2,7,10,.2) 72%,rgba(0,0,0,.58) 115%);z-index:2}
|
|
.hidden{display:none!important}.glass{background:linear-gradient(145deg,rgba(7,22,25,.78),rgba(9,19,27,.56));border:1px solid rgba(246,215,139,.28);box-shadow:inset 0 0 28px rgba(113,244,220,.035),0 10px 34px rgba(0,0,0,.25);backdrop-filter:blur(10px)}
|
|
#loading{position:fixed;inset:0;z-index:100;background:#030b0e;display:flex;align-items:center;justify-content:center;letter-spacing:.18em;color:var(--gold)}
|
|
#loading .mark{width:34px;height:34px;margin-right:14px;border:1px solid var(--gold2);transform:rotate(45deg);animation:spin 2.2s linear infinite;box-shadow:0 0 24px rgba(113,244,220,.3)}
|
|
@keyframes spin{to{transform:rotate(405deg)}}
|
|
#veil{position:fixed;inset:0;z-index:40;display:grid;place-items:center;background:radial-gradient(circle at 50% 34%,rgba(18,56,59,.42),rgba(1,7,10,.88) 65%),linear-gradient(130deg,#06181a,#080b18);transition:opacity .3s}
|
|
#veil:before,#veil:after{content:"";position:absolute;inset:22px;border:1px solid rgba(246,215,139,.19);pointer-events:none}#veil:after{inset:30px;border-color:rgba(113,244,220,.08)}
|
|
.intro{position:relative;width:min(870px,calc(100vw - 42px));padding:48px 55px 38px;text-align:center;overflow:hidden}
|
|
.intro:before{content:"ᚱ ✦ ᛉ ◇ ᛟ";position:absolute;left:0;right:0;top:15px;color:rgba(246,215,139,.3);letter-spacing:1.1em;font-size:11px}
|
|
.eyebrow{font-size:11px;letter-spacing:.48em;color:var(--cyan);text-transform:uppercase;margin-bottom:14px}.intro h1{font-family:Georgia,"Noto Serif SC",serif;font-size:clamp(38px,7vw,76px);font-weight:500;line-height:.95;margin:0;background:linear-gradient(#fff8df 10%,#e5bd68 65%,#8e6334);-webkit-background-clip:text;color:transparent;text-shadow:0 9px 38px rgba(246,215,139,.12);letter-spacing:.08em}.en-title{font-family:Georgia,serif;font-size:12px;letter-spacing:.72em;color:rgba(233,224,197,.45);margin:14px 0 28px}.intro .lore{max-width:660px;margin:0 auto 27px;color:rgba(233,224,197,.72);line-height:1.9;font-size:14px}.intro .lore em{font-style:normal;color:var(--gold)}
|
|
.start-btn{border:1px solid rgba(246,215,139,.65);background:linear-gradient(90deg,rgba(105,74,36,.48),rgba(25,80,75,.42),rgba(105,74,36,.48));color:#fff3cf;padding:14px 37px;min-width:210px;font:500 13px inherit;letter-spacing:.32em;cursor:pointer;clip-path:polygon(7px 0,calc(100% - 7px) 0,100% 7px,100% calc(100% - 7px),calc(100% - 7px) 100%,7px 100%,0 calc(100% - 7px),0 7px);transition:.2s}.start-btn:hover{filter:brightness(1.28);box-shadow:0 0 30px rgba(113,244,220,.16);transform:translateY(-1px)}
|
|
.controls-row{display:flex;justify-content:center;gap:20px;flex-wrap:wrap;margin-top:28px;color:rgba(233,224,197,.48);font-size:11px;letter-spacing:.06em}.controls-row b{display:inline-grid;place-items:center;height:22px;min-width:22px;padding:0 5px;margin-right:5px;border:1px solid rgba(233,224,197,.24);color:rgba(255,255,255,.78);font-weight:500}
|
|
#hud{position:fixed;inset:0;z-index:5;pointer-events:none}.top-left{position:absolute;left:22px;top:20px;min-width:258px;padding:13px 15px}.brand{font-family:Georgia,"Noto Serif SC",serif;color:var(--gold);font-size:15px;letter-spacing:.18em}.brand small{display:block;color:rgba(233,224,197,.38);font:8px system-ui;letter-spacing:.42em;margin-top:4px}.readout{display:flex;gap:13px;margin-top:12px;padding-top:10px;border-top:1px solid rgba(246,215,139,.12);font:10px ui-monospace,monospace;color:rgba(233,224,197,.6)}.readout strong{color:#d8f7ec;font-weight:500}
|
|
#minimapWrap{position:absolute;right:22px;top:20px;width:170px;padding:10px}.map-title{display:flex;justify-content:space-between;align-items:center;color:rgba(246,215,139,.7);font:9px ui-monospace,monospace;letter-spacing:.18em;margin-bottom:7px}#minimap{width:148px;height:148px;border-radius:50%;border:1px solid rgba(246,215,139,.3);box-shadow:inset 0 0 24px rgba(0,0,0,.9)}
|
|
#objective{position:absolute;right:22px;top:226px;width:210px;padding:13px 14px}.obj-kicker{color:var(--cyan);font-size:8px;letter-spacing:.3em}.obj-name{margin:6px 0 5px;font-family:Georgia,"Noto Serif SC",serif;color:#fff0c6;font-size:14px}.obj-copy{font-size:10px;color:rgba(233,224,197,.52);line-height:1.65}.obj-distance{margin-top:8px;color:rgba(113,244,220,.78);font:9px ui-monospace,monospace}
|
|
#crosshair{position:absolute;left:50%;top:50%;width:20px;height:20px;transform:translate(-50%,-50%)}#crosshair:before,#crosshair:after{content:"";position:absolute;background:rgba(255,247,218,.9);box-shadow:0 0 4px rgba(0,0,0,.8)}#crosshair:before{left:9px;top:2px;width:1px;height:16px}#crosshair:after{left:2px;top:9px;width:16px;height:1px}#crosshair i{position:absolute;inset:6px;border:1px solid rgba(113,244,220,.5);transform:rotate(45deg)}
|
|
#targetText{position:absolute;left:50%;top:calc(50% + 23px);transform:translateX(-50%);white-space:nowrap;color:rgba(255,247,218,.74);font-size:9px;letter-spacing:.12em;text-shadow:0 1px 3px #000}
|
|
#toast{position:absolute;left:50%;top:23%;transform:translate(-50%,-8px);padding:9px 17px;border:1px solid rgba(246,215,139,.25);background:rgba(5,15,18,.74);font-size:11px;letter-spacing:.16em;color:#fff0c9;opacity:0;transition:.25s}#toast.show{opacity:1;transform:translate(-50%,0)}
|
|
#hotbar{position:absolute;left:50%;bottom:24px;transform:translateX(-50%);display:flex;gap:6px;padding:7px}.slot{position:relative;width:54px;height:54px;border:1px solid rgba(233,224,197,.17);background:rgba(1,8,11,.63);display:grid;place-items:center;transition:.14s}.slot.active{border-color:var(--gold);background:rgba(78,63,34,.72);transform:translateY(-5px);box-shadow:0 0 20px rgba(246,215,139,.16)}.slot .key{position:absolute;left:4px;top:2px;color:rgba(233,224,197,.4);font:8px ui-monospace}.slot .cube{width:25px;height:25px;border:1px solid rgba(255,255,255,.14);box-shadow:inset -6px -6px 0 rgba(0,0,0,.2),inset 3px 3px 0 rgba(255,255,255,.08);image-rendering:pixelated}.slot .name{position:absolute;bottom:2px;left:0;right:0;text-align:center;font-size:7px;color:rgba(255,255,255,.62)}
|
|
#meters{position:absolute;left:22px;bottom:26px;width:230px}.meter-label{display:flex;justify-content:space-between;color:rgba(233,224,197,.45);font:8px ui-monospace;margin-bottom:5px}.meter{height:3px;background:rgba(255,255,255,.1);overflow:hidden}.meter>i{display:block;height:100%;width:100%;background:linear-gradient(90deg,var(--gold2),var(--gold));transition:width .2s}.hints{margin-top:10px;color:rgba(233,224,197,.34);font-size:9px;line-height:1.8}
|
|
#debug{position:absolute;left:50%;top:16px;transform:translateX(-50%);font:9px ui-monospace;color:rgba(233,224,197,.5);letter-spacing:.06em}
|
|
@media(max-width:760px){#objective,#meters{display:none}.top-left{left:10px;top:10px;transform:scale(.86);transform-origin:top left}#minimapWrap{right:10px;top:10px;transform:scale(.78);transform-origin:top right}.slot{width:38px;height:44px}.slot .cube{width:19px;height:19px}.intro{padding:42px 22px 28px}.controls-row{gap:8px}.en-title{letter-spacing:.42em}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="loading"><div class="mark"></div><span>正在唤醒远古世界</span></div>
|
|
<main id="game"></main>
|
|
<section id="veil">
|
|
<div class="intro glass">
|
|
<div class="eyebrow">程序生成 · 无限神话世界</div>
|
|
<h1>神域方块</h1><div class="en-title">MYTHOS CRAFT</div>
|
|
<p class="lore">诸神离去后,九种原初方块仍在梦中生长。穿过<em>日冕圣门</em>,寻找倒悬天岛、月神仪庭与星骸巨龙;你破坏或放置的每一块石头,都会留在这个种子世界里。</p>
|
|
<button class="start-btn" id="startBtn">踏 入 神 域</button>
|
|
<div class="controls-row"><span><b>WASD</b>移动</span><span><b>鼠标</b>环视</span><span><b>左键</b>破坏</span><span><b>右键</b>放置</span><span><b>SPACE</b>跳跃</span><span><b>1—9</b>选方块</span></div>
|
|
</div>
|
|
</section>
|
|
<div id="hud" class="hidden">
|
|
<div class="top-left glass"><div class="brand">神域方块<small>MYTHOS CRAFT · SEED 9C4F</small></div><div class="readout"><span>坐标 <strong id="coords">0 / 0 / 0</strong></span><span>地域 <strong id="biome">翡翠荒原</strong></span><span>时刻 <strong id="daytime">晨曦</strong></span></div></div>
|
|
<div id="minimapWrap" class="glass"><div class="map-title"><span>星盘地志</span><span id="heading">N</span></div><canvas id="minimap" width="296" height="296"></canvas></div>
|
|
<div id="objective" class="glass"><div class="obj-kicker">最近的神话遗迹</div><div class="obj-name" id="objName">日冕圣门</div><div class="obj-copy" id="objCopy">旧日太阳神用鎏金符石铸成的天空之钥。</div><div class="obj-distance" id="objDistance">距离 0 格</div></div>
|
|
<div id="crosshair"><i></i></div><div id="targetText">凝视方块以采集</div><div id="toast"></div><div id="debug"></div>
|
|
<div id="meters"><div class="meter-label"><span>神行者体力</span><span id="staminaText">100</span></div><div class="meter"><i id="stamina"></i></div><div class="hints">SHIFT 疾跑 · SPACE 跳跃 · R 返回星印<br>滚轮切换方块 · ESC 释放鼠标</div></div>
|
|
<div id="hotbar" class="glass"></div>
|
|
</div>
|
|
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
|
|
<script>
|
|
'use strict';
|
|
(() => {
|
|
if (!window.THREE) {
|
|
document.getElementById('loading').innerHTML = '无法载入 Three.js,请检查网络后刷新';
|
|
return;
|
|
}
|
|
|
|
const $ = (q) => document.querySelector(q);
|
|
const game = $('#game'), loading = $('#loading'), veil = $('#veil'), hud = $('#hud');
|
|
const coordsEl = $('#coords'), biomeEl = $('#biome'), daytimeEl = $('#daytime');
|
|
const targetText = $('#targetText'), hotbar = $('#hotbar'), toastEl = $('#toast');
|
|
const mapCanvas = $('#minimap'), mapCtx = mapCanvas.getContext('2d');
|
|
const SEED = 0x9c4f21, CHUNK = 16, WORLD_H = 64, VIEW_RADIUS = 3;
|
|
const AIR = 0, GRASS = 1, DIRT = 2, STONE = 3, RUNE = 4, OBSIDIAN = 5;
|
|
const GOLD = 6, CRYSTAL = 7, WOOD = 8, LEAVES = 9;
|
|
const BLOCKS = [
|
|
null,
|
|
{name:'神域草', short:'草地', side:1, top:0, bottom:2, css:'#4f9b68', tint:[.92,1,.94]},
|
|
{name:'沃土', short:'泥土', side:2, top:2, bottom:2, css:'#845a3b', tint:[1,.94,.88]},
|
|
{name:'远古岩', short:'石头', side:3, top:3, bottom:3, css:'#7f8784', tint:[.94,.97,.96]},
|
|
{name:'符文石', short:'符石', side:4, top:4, bottom:3, css:'#3c776f', tint:[.79,1,.94]},
|
|
{name:'夜曜石', short:'夜曜', side:5, top:5, bottom:5, css:'#302845', tint:[.86,.79,1]},
|
|
{name:'鎏金砖', short:'鎏金', side:6, top:6, bottom:6, css:'#c28a35', tint:[1,.91,.65]},
|
|
{name:'星辉晶', short:'星晶', side:7, top:7, bottom:7, css:'#55cdd1', tint:[.72,1,1]},
|
|
{name:'世界木', short:'神木', side:9, top:8, bottom:8, css:'#765137', tint:[1,.91,.79]},
|
|
{name:'永青叶', short:'永叶', side:10, top:10, bottom:10, css:'#39725c', tint:[.75,1,.82]}
|
|
];
|
|
|
|
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return ((t^t>>>14)>>>0)/4294967296}}
|
|
class Perlin {
|
|
constructor(seed){
|
|
const rnd=mulberry32(seed), p=Array.from({length:256},(_,i)=>i);
|
|
for(let i=255;i>0;i--){const j=Math.floor(rnd()*(i+1));[p[i],p[j]]=[p[j],p[i]]}
|
|
this.p=new Uint16Array(512);for(let i=0;i<512;i++)this.p[i]=p[i&255];
|
|
}
|
|
fade(t){return t*t*t*(t*(t*6-15)+10)} lerp(a,b,t){return a+t*(b-a)}
|
|
grad(h,x,y,z){const q=h&15,u=q<8?x:y,v=q<4?y:q===12||q===14?x:z;return ((q&1)?-u:u)+((q&2)?-v:v)}
|
|
noise3(x,y,z){
|
|
const X=Math.floor(x)&255,Y=Math.floor(y)&255,Z=Math.floor(z)&255;
|
|
x-=Math.floor(x);y-=Math.floor(y);z-=Math.floor(z);
|
|
const u=this.fade(x),v=this.fade(y),w=this.fade(z),p=this.p;
|
|
const A=p[X]+Y,AA=p[A]+Z,AB=p[A+1]+Z,B=p[X+1]+Y,BA=p[B]+Z,BB=p[B+1]+Z;
|
|
return this.lerp(this.lerp(this.lerp(this.grad(p[AA],x,y,z),this.grad(p[BA],x-1,y,z),u),this.lerp(this.grad(p[AB],x,y-1,z),this.grad(p[BB],x-1,y-1,z),u),v),this.lerp(this.lerp(this.grad(p[AA+1],x,y,z-1),this.grad(p[BA+1],x-1,y,z-1),u),this.lerp(this.grad(p[AB+1],x,y-1,z-1),this.grad(p[BB+1],x-1,y-1,z-1),u),v),w);
|
|
}
|
|
noise2(x,z){return this.noise3(x,31.416,z)}
|
|
}
|
|
const perlin=new Perlin(SEED), heightCache=new Map();
|
|
function fbm(x,z,oct=4,persist=.5){let sum=0,amp=1,freq=1,norm=0;for(let i=0;i<oct;i++){sum+=perlin.noise2(x*freq,z*freq)*amp;norm+=amp;amp*=persist;freq*=2.03}return sum/norm}
|
|
function hash2(x,z,s=SEED){let h=Math.imul(x|0,374761393)^Math.imul(z|0,668265263)^s;h=Math.imul(h^(h>>>13),1274126177);return ((h^(h>>>16))>>>0)/4294967295}
|
|
function hash3(x,y,z){return hash2((x|0)^Math.imul(y|0,1597334677),z|0,SEED^0x51ed270b)}
|
|
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)), floorDiv=(a,b)=>Math.floor(a/b), mod=(a,b)=>((a%b)+b)%b;
|
|
function heightAt(x,z){
|
|
x=Math.floor(x);z=Math.floor(z);const key=x+','+z;if(heightCache.has(key))return heightCache.get(key);
|
|
const continent=.5+.5*fbm(x*.006,z*.006,5,.52), hills=fbm(x*.026,z*.026,4,.52);
|
|
const ridge=1-Math.abs(perlin.noise2(x*.012-30,z*.012+18));
|
|
const highland=Math.max(0,fbm(x*.003+70,z*.003-40,3,.55)-.05);
|
|
let h=Math.floor(6+continent*10+hills*3+ridge*highland*15);
|
|
h=clamp(h,4,31);heightCache.set(key,h);if(heightCache.size>240000)heightCache.clear();return h;
|
|
}
|
|
function biomeAt(x,z){
|
|
const h=heightAt(x,z),m=perlin.noise2(x*.009+120,z*.009-90);
|
|
if(h>24)return {name:'诸神脊岭',color:'#727c7d'};
|
|
if(m>.28)return {name:'永青密林',color:'#326b55'};
|
|
if(m<-.3)return {name:'月盐荒漠',color:'#9d8b72'};
|
|
if(perlin.noise2(x*.004,z*.004)>.38)return {name:'星落高原',color:'#477c75'};
|
|
return {name:'翡翠荒原',color:'#548766'};
|
|
}
|
|
function terrainBlockAt(x,y,z){
|
|
if(y<0||y>=WORLD_H)return AIR;if(y===0)return OBSIDIAN;
|
|
const h=heightAt(x,z);if(y>h)return AIR;
|
|
if(y>3&&y<h-3){
|
|
const cave=perlin.noise3(x*.074,y*.083,z*.074)+.36*perlin.noise3(x*.151+8,y*.143,z*.151-5);
|
|
if(cave>.69)return AIR;
|
|
}
|
|
if(y===h)return h>25?STONE:GRASS;
|
|
if(y>=h-3)return DIRT;
|
|
const ore=hash3(x,y,z);
|
|
if(y<20&&ore>.994)return CRYSTAL;if(y<15&&ore<.004)return GOLD;if(y<24&&ore>.987)return RUNE;
|
|
return STONE;
|
|
}
|
|
|
|
function makeAtlas(){
|
|
const S=64,C=4,c=document.createElement('canvas');c.width=c.height=S*C;const g=c.getContext('2d');g.imageSmoothingEnabled=false;
|
|
const rand=mulberry32(SEED^0xada5), xy=(tile)=>[(tile%C)*S,Math.floor(tile/C)*S];
|
|
function noiseTile(tile,base,spots,amount=150){const [ox,oy]=xy(tile);g.fillStyle=base;g.fillRect(ox,oy,S,S);for(let i=0;i<amount;i++){const k=(rand()*spots.length)|0,sz=2+(rand()*5|0);g.globalAlpha=.25+rand()*.5;g.fillStyle=spots[k];g.fillRect(ox+(rand()*S|0),oy+(rand()*S|0),sz,sz)}g.globalAlpha=1}
|
|
noiseTile(0,'#4f925d',['#82b865','#315f48','#b1c86d']);
|
|
noiseTile(1,'#76533a',['#5a3d2d','#956b45','#3d7049']);{const[o,y]=xy(1);g.fillStyle='#4d8d59';g.fillRect(o,y,S,12);g.fillStyle='#79af63';for(let x=0;x<S;x+=6)g.fillRect(o+x,y+9,4,5+(x%12));}
|
|
noiseTile(2,'#79543a',['#563825','#9b6a44','#45382d'],180);
|
|
noiseTile(3,'#707876',['#929b96','#555c5b','#3f4d4e'],170);
|
|
noiseTile(4,'#315d59',['#427d73','#183e40','#789d86'],100);{const[o,y]=xy(4);g.strokeStyle='#77d8be';g.lineWidth=4;g.shadowColor='#63e9d0';g.shadowBlur=7;g.beginPath();g.moveTo(o+17,y+12);g.lineTo(o+17,y+49);g.lineTo(o+33,y+32);g.lineTo(o+47,y+48);g.moveTo(o+17,y+31);g.lineTo(o+39,y+12);g.stroke();g.shadowBlur=0}
|
|
noiseTile(5,'#201a31',['#3d3158','#151220','#695081'],120);{const[o,y]=xy(5);g.strokeStyle='rgba(160,114,204,.5)';g.lineWidth=2;for(let i=0;i<6;i++){g.beginPath();g.moveTo(o+rand()*S,y);g.lineTo(o+rand()*S,y+S);g.stroke()}}
|
|
noiseTile(6,'#aa762c',['#e5bd58','#725024','#f2d87e'],100);{const[o,y]=xy(6);g.strokeStyle='#5f4221';g.lineWidth=3;g.strokeRect(o+3,y+3,S-6,S-6);g.beginPath();g.moveTo(o+6,y+32);g.lineTo(o+32,y+6);g.lineTo(o+58,y+32);g.lineTo(o+32,y+58);g.closePath();g.stroke()}
|
|
noiseTile(7,'#43aeb2',['#a8ffff','#267c91','#6ee4d4'],90);{const[o,y]=xy(7);g.fillStyle='rgba(221,255,255,.52)';g.beginPath();g.moveTo(o+31,y+3);g.lineTo(o+58,y+28);g.lineTo(o+37,y+61);g.lineTo(o+6,y+38);g.closePath();g.fill();g.strokeStyle='#dbffff';g.lineWidth=2;g.beginPath();g.moveTo(o+31,y+3);g.lineTo(o+37,y+61);g.moveTo(o+6,y+38);g.lineTo(o+58,y+28);g.stroke()}
|
|
noiseTile(8,'#6d4b34',['#3b2b24','#956a42'],60);{const[o,y]=xy(8);g.strokeStyle='#3d2a21';g.lineWidth=4;for(let r=8;r<31;r+=8){g.beginPath();g.arc(o+32,y+32,r,0,Math.PI*2);g.stroke()}}
|
|
noiseTile(9,'#67472f',['#966744','#3f2d25'],80);{const[o,y]=xy(9);g.strokeStyle='#3a2821';g.lineWidth=4;for(let x=8;x<S;x+=14){g.beginPath();g.moveTo(o+x,y);g.lineTo(o+x-5,y+S);g.stroke()}}
|
|
noiseTile(10,'#32664f',['#4f9167','#1f493f','#76a965'],220);{const[o,y]=xy(10);g.fillStyle='rgba(151,220,143,.38)';for(let i=0;i<14;i++)g.fillRect(o+(rand()*S|0),y+(rand()*S|0),4,4)}
|
|
noiseTile(11,'#a5a69d',['#c7c7b6','#777f7c'],100);noiseTile(12,'#5f6574',['#9195a4','#363b49'],100);noiseTile(13,'#536d75',['#79a4a1','#263c46'],100);noiseTile(14,'#806951',['#ac9472','#51483d'],100);noiseTile(15,'#172f3c',['#315c66','#0b202c'],100);
|
|
const texture=new THREE.CanvasTexture(c);texture.colorSpace=THREE.SRGBColorSpace;texture.magFilter=THREE.NearestFilter;texture.minFilter=THREE.NearestFilter;texture.generateMipmaps=false;return texture;
|
|
}
|
|
const atlas=makeAtlas();
|
|
|
|
const scene=new THREE.Scene();scene.background=new THREE.Color(0x183b49);scene.fog=new THREE.FogExp2(0x183b49,.0115);
|
|
const camera=new THREE.PerspectiveCamera(72,innerWidth/innerHeight,.05,230);camera.rotation.order='YXZ';
|
|
const renderer=new THREE.WebGLRenderer({antialias:false,powerPreference:'high-performance'});renderer.setPixelRatio(Math.min(devicePixelRatio,1.65));renderer.setSize(innerWidth,innerHeight);renderer.outputColorSpace=THREE.SRGBColorSpace;renderer.toneMapping=THREE.ACESFilmicToneMapping;renderer.toneMappingExposure=1.12;game.appendChild(renderer.domElement);
|
|
const worldMaterial=new THREE.MeshStandardMaterial({map:atlas,vertexColors:true,roughness:.88,metalness:.05,side:THREE.FrontSide});
|
|
const hemi=new THREE.HemisphereLight(0x9de8ef,0x18201b,1.32);scene.add(hemi);
|
|
const sun=new THREE.DirectionalLight(0xffe5b1,2.15);sun.position.set(60,90,28);scene.add(sun);
|
|
const moonGlow=new THREE.PointLight(0x73e9dc,18,55,1.8);moonGlow.position.set(38,35,24);scene.add(moonGlow);
|
|
|
|
function makeSkyDetails(){
|
|
const rnd=mulberry32(SEED), starGeo=new THREE.BufferGeometry(),arr=[];
|
|
for(let i=0;i<520;i++){const a=rnd()*Math.PI*2,b=.12+rnd()*1.18,r=145+20*rnd();arr.push(Math.sin(b)*Math.cos(a)*r,Math.cos(b)*r+18,Math.sin(b)*Math.sin(a)*r)}
|
|
starGeo.setAttribute('position',new THREE.Float32BufferAttribute(arr,3));const stars=new THREE.Points(starGeo,new THREE.PointsMaterial({color:0xc9fff3,size:.52,transparent:true,opacity:.55,sizeAttenuation:true,depthWrite:false}));scene.add(stars);
|
|
const sunDisc=new THREE.Mesh(new THREE.SphereGeometry(4,18,12),new THREE.MeshBasicMaterial({color:0xffe0a0,fog:false}));sunDisc.name='sunDisc';scene.add(sunDisc);
|
|
const moonDisc=new THREE.Mesh(new THREE.SphereGeometry(2.1,16,10),new THREE.MeshBasicMaterial({color:0x9fffee,fog:false}));moonDisc.position.set(88,65,-90);scene.add(moonDisc);
|
|
return {stars,sunDisc,moonDisc};
|
|
}
|
|
const sky=makeSkyDetails();
|
|
|
|
const chunks=new Map(), editsByChunk=new Map(), loadQueue=[];
|
|
const chunkKey=(cx,cz)=>cx+','+cz, voxelIndex=(x,y,z)=>y*CHUNK*CHUNK+z*CHUNK+x;
|
|
function inChunk(chunk,x,z){return x>=chunk.cx*CHUNK&&x<(chunk.cx+1)*CHUNK&&z>=chunk.cz*CHUNK&&z<(chunk.cz+1)*CHUNK}
|
|
function put(chunk,x,y,z,type,mode='replace'){
|
|
x=Math.floor(x);y=Math.floor(y);z=Math.floor(z);if(y<0||y>=WORLD_H||!inChunk(chunk,x,z))return;
|
|
const i=voxelIndex(mod(x,CHUNK),y,mod(z,CHUNK)),old=chunk.data[i];
|
|
if(mode==='air'&&old!==AIR)return;if(mode==='solid'&&old===AIR)return;chunk.data[i]=type;
|
|
}
|
|
function box(chunk,x0,y0,z0,x1,y1,z1,type,mode='replace'){
|
|
for(let y=y0;y<=y1;y++)for(let z=z0;z<=z1;z++)for(let x=x0;x<=x1;x++)put(chunk,x,y,z,type,mode);
|
|
}
|
|
function sphere(chunk,cx,cy,cz,r,type,mode='replace'){
|
|
const rr=r*r;for(let y=Math.floor(cy-r);y<=Math.ceil(cy+r);y++)for(let z=Math.floor(cz-r);z<=Math.ceil(cz+r);z++)for(let x=Math.floor(cx-r);x<=Math.ceil(cx+r);x++)if((x-cx)**2+(y-cy)**2+(z-cz)**2<=rr)put(chunk,x,y,z,type,mode);
|
|
}
|
|
function lineBlocks(chunk,a,b,type,r=.6,mode='replace'){
|
|
const len=Math.hypot(b[0]-a[0],b[1]-a[1],b[2]-a[2]),n=Math.ceil(len*1.8);
|
|
for(let i=0;i<=n;i++){const t=i/n;sphere(chunk,a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t,a[2]+(b[2]-a[2])*t,r,type,mode)}
|
|
}
|
|
function overlaps(chunk,x0,x1,z0,z1){const ax=chunk.cx*CHUNK,az=chunk.cz*CHUNK;return ax<=x1&&ax+CHUNK-1>=x0&&az<=z1&&az+CHUNK-1>=z0}
|
|
|
|
const LANDMARKS=[
|
|
{name:'日冕圣门',x:0,z:-24,copy:'旧日太阳神用鎏金符石铸成的天空之钥。'},
|
|
{name:'永恒世界树',x:-36,z:27,copy:'它的根须穿过洞窟,枝叶托举着失落的星辰。'},
|
|
{name:'月神仪庭',x:39,z:25,copy:'潮汐祭司在此丈量月亮与梦的距离。'},
|
|
{name:'星骸巨龙',x:31,z:-47,copy:'一头从天幕坠落的古龙,只余发光的脊骨。'},
|
|
{name:'倒悬天岛',x:5,z:48,copy:'被神链锚定在云上的小型王国遗址。'}
|
|
];
|
|
const templeBase=heightAt(0,-24)+2, treeBase=heightAt(-36,27)+1, moonBase=heightAt(39,25)+2, dragonBase=heightAt(31,-47)+3;
|
|
|
|
function decorateNature(chunk){
|
|
const x0=chunk.cx*CHUNK,z0=chunk.cz*CHUNK;
|
|
// Deterministic trees. Candidate cells are evaluated beyond the chunk edge so crowns join seamlessly.
|
|
for(let gz=floorDiv(z0-5,9);gz<=floorDiv(z0+CHUNK+5,9);gz++)for(let gx=floorDiv(x0-5,9);gx<=floorDiv(x0+CHUNK+5,9);gx++){
|
|
const chance=hash2(gx,gz,SEED^0x4b1d);if(chance<.57)continue;
|
|
const tx=gx*9+2+Math.floor(hash2(gx,gz,71)*6),tz=gz*9+2+Math.floor(hash2(gx,gz,109)*6),h=heightAt(tx,tz);
|
|
const bio=biomeAt(tx,tz);if(h>24||bio.name==='月盐荒漠'||Math.abs(heightAt(tx+1,tz)-h)>1)continue;
|
|
if(Math.hypot(tx,tz+24)<15||Math.hypot(tx+36,tz-27)<11||Math.hypot(tx-39,tz-25)<12)continue;
|
|
const tall=3+Math.floor(hash2(gx,gz,131)*3);for(let y=h+1;y<=h+tall;y++)put(chunk,tx,y,tz,WOOD);
|
|
sphere(chunk,tx,h+tall+1,tz,2.45,LEAVES,'air');sphere(chunk,tx+1,h+tall,tz-1,1.8,LEAVES,'air');
|
|
}
|
|
// Small star-crystal outcrops.
|
|
for(let gz=floorDiv(z0-3,13);gz<=floorDiv(z0+CHUNK+3,13);gz++)for(let gx=floorDiv(x0-3,13);gx<=floorDiv(x0+CHUNK+3,13);gx++){
|
|
if(hash2(gx,gz,991)<.78)continue;const x=gx*13+3+(hash2(gx,gz,37)*7|0),z=gz*13+3+(hash2(gx,gz,43)*7|0),y=heightAt(x,z)+1;
|
|
const n=2+(hash2(gx,gz,59)*3|0);for(let i=0;i<n;i++){const dx=(hash2(gx+i,gz,61)*3|0)-1,dz=(hash2(gx,gz+i,67)*3|0)-1;lineBlocks(chunk,[x+dx,y,z+dz],[x+dx,y+2+(i%3),z+dz],CRYSTAL,.48,'air')}
|
|
}
|
|
}
|
|
|
|
function buildWanderingSpires(chunk){
|
|
const x0=chunk.cx*CHUNK,z0=chunk.cz*CHUNK;
|
|
for(let gz=floorDiv(z0-8,58);gz<=floorDiv(z0+CHUNK+8,58);gz++)for(let gx=floorDiv(x0-8,58);gx<=floorDiv(x0+CHUNK+8,58);gx++){
|
|
if(hash2(gx,gz,0x2211)<.34)continue;const x=gx*58+10+(hash2(gx,gz,4)*38|0),z=gz*58+10+(hash2(gx,gz,8)*38|0);
|
|
if(Math.min(...LANDMARKS.map(m=>Math.hypot(x-m.x,z-m.z)))<17)continue;
|
|
const base=heightAt(x,z)+1,height=7+(hash2(gx,gz,12)*9|0),rad=2+(hash2(gx,gz,16)>.65?1:0);
|
|
for(let y=base;y<base+height;y++){const rr=y>base+height-3?1:rad;for(let dz=-rr;dz<=rr;dz++)for(let dx=-rr;dx<=rr;dx++)if(dx*dx+dz*dz<=rr*rr+.2){const edge=Math.abs(dx)===rr||Math.abs(dz)===rr;put(chunk,x+dx,y,z+dz,edge&&y%4===0?RUNE:OBSIDIAN)}}
|
|
sphere(chunk,x,base+height,z,1.35,CRYSTAL,'air');
|
|
}
|
|
}
|
|
|
|
function buildTemple(chunk){
|
|
const X=0,Z=-24,B=templeBase;if(!overlaps(chunk,-15,15,Z-13,Z+23))return;
|
|
// Three-tier, rounded ceremonial platform.
|
|
for(let y=B-2;y<=B;y++){const shrink=B-y;for(let z=Z-10+shrink;z<=Z+10-shrink;z++)for(let x=-12+shrink;x<=12-shrink;x++)if(Math.abs(x)<=10||Math.abs(z-Z)<=8){const edge=Math.abs(x)>=10-shrink||Math.abs(z-Z)>=8-shrink;put(chunk,x,y,z,edge?RUNE:STONE)}}
|
|
// Long processional stair descending toward the player spawn.
|
|
for(let s=0;s<13;s++){const z=Z+11+s,y=B-Math.floor(s/4);for(let x=-4;x<=4;x++)put(chunk,x,y,z,(x===-4||x===4||s%4===0)?GOLD:STONE)}
|
|
// Sun gate: paired pylons and a stepped lintel.
|
|
for(const px of [-5,5]){box(chunk,px-1,B+1,Z+6,px+1,B+9,Z+8,RUNE);box(chunk,px,B+2,Z+7,px,B+8,Z+7,GOLD);sphere(chunk,px,B+11,Z+7,1.55,CRYSTAL,'air')}
|
|
box(chunk,-6,B+9,Z+6,6,B+10,Z+8,GOLD);box(chunk,-4,B+11,Z+7,4,B+11,Z+7,RUNE);
|
|
// Four sanctum towers, each capped with a luminous crystal brazier.
|
|
for(const [px,pz] of [[-10,Z-7],[10,Z-7],[-10,Z+7],[10,Z+7]]){
|
|
for(let y=B+1;y<=B+12;y++)for(let dz=-1;dz<=1;dz++)for(let dx=-1;dx<=1;dx++){const shell=Math.abs(dx)===1||Math.abs(dz)===1;put(chunk,px+dx,y,pz+dz,(y%4===0||!shell)?GOLD:RUNE)}
|
|
for(let d=-3;d<=3;d++){put(chunk,px+d,B+12,pz,GOLD);put(chunk,px,B+12,pz+d,GOLD)}sphere(chunk,px,B+14,pz,1.45,CRYSTAL,'air');
|
|
}
|
|
// Altar, constellation mosaic and suspended halo.
|
|
for(let r=4;r>=1;r--)for(let z=Z-r;z<=Z+r;z++)for(let x=-r;x<=r;x++)put(chunk,x,B+5-r,z,(Math.abs(x)+Math.abs(z-Z))%3===0?GOLD:RUNE);
|
|
lineBlocks(chunk,[0,B+2,Z],[0,B+8,Z],CRYSTAL,.72);
|
|
const hy=B+16;for(let i=0;i<64;i++){const a=i/64*Math.PI*2;put(chunk,Math.round(Math.cos(a)*7),hy+Math.round(Math.sin(a)*2),Z+Math.round(Math.sin(a)*7),i%8===0?CRYSTAL:GOLD)}
|
|
// Rune colonnade along both sides.
|
|
for(const sx of [-1,1])for(let p=-6;p<=6;p+=4){const x=sx*8,z=Z+p;box(chunk,x,B+1,z,x,B+7,z,RUNE);put(chunk,x,B+3,z,GOLD);put(chunk,x,B+7,z,CRYSTAL)}
|
|
}
|
|
|
|
function buildWorldTree(chunk){
|
|
const X=-36,Z=27,B=treeBase;if(!overlaps(chunk,X-15,X+15,Z-15,Z+15))return;
|
|
// Roots crawl across the terrain before converging into a twisting trunk.
|
|
const roots=[[11,1],[9,7],[-10,5],[-12,-2],[-7,-10],[5,-11]];
|
|
for(const [dx,dz] of roots){const ey=heightAt(X+dx,Z+dz)+1;lineBlocks(chunk,[X,B,Z],[X+dx,ey,Z+dz],WOOD,1.0)}
|
|
for(let y=0;y<=19;y++){const r=y<7?2.25:y<14?1.65:1.15;sphere(chunk,X+Math.sin(y*.52)*.8,B+y,Z+Math.cos(y*.43)*.7,r,WOOD)}
|
|
const branches=[[-10,16,-4],[-8,20,7],[9,17,5],[10,21,-5],[-3,23,10],[5,24,8],[1,25,-9]];
|
|
for(const [dx,dy,dz] of branches){lineBlocks(chunk,[X,B+12,Z],[X+dx,B+dy,Z+dz],WOOD,.85);sphere(chunk,X+dx,B+dy,Z+dz,4.1,LEAVES,'air');sphere(chunk,X+dx*.72,B+dy+2,Z+dz*.72,3.2,LEAVES,'air')}
|
|
sphere(chunk,X,B+24,Z,5.4,LEAVES,'air');
|
|
// A golden shrine is hidden inside the roots.
|
|
box(chunk,X-2,B,Z-3,X+2,B,Z+1,GOLD);box(chunk,X-2,B+1,Z-2,X-2,B+4,Z,RUNE);box(chunk,X+2,B+1,Z-2,X+2,B+4,Z,RUNE);box(chunk,X-2,B+4,Z-2,X+2,B+4,Z,GOLD);put(chunk,X,B+1,Z-2,CRYSTAL);
|
|
for(let y=B+22;y<=B+29;y+=2)sphere(chunk,X+(y%3)-1,y,Z,0.75,CRYSTAL,'air');
|
|
}
|
|
|
|
function buildMoonShrine(chunk){
|
|
const X=39,Z=25,B=moonBase;if(!overlaps(chunk,X-13,X+13,Z-13,Z+13))return;
|
|
for(let y=B-2;y<=B;y++){const r=11-(B-y);for(let z=Z-r;z<=Z+r;z++)for(let x=X-r;x<=X+r;x++){const d=Math.hypot(x-X,z-Z);if(d<=r)put(chunk,x,y,z,d>r-1.4?CRYSTAL:(x+z)%5===0?GOLD:STONE)}}
|
|
// Eight astronomer columns and an overhead orbit.
|
|
for(let i=0;i<8;i++){const a=i/8*Math.PI*2,px=Math.round(X+Math.cos(a)*8),pz=Math.round(Z+Math.sin(a)*8);box(chunk,px,B+1,pz,px,B+8,pz,i%2?RUNE:STONE);put(chunk,px,B+4,pz,GOLD);sphere(chunk,px,B+9,pz,1.05,CRYSTAL,'air')}
|
|
const oy=B+11;for(let i=0;i<72;i++){const a=i/72*Math.PI*2;put(chunk,Math.round(X+Math.cos(a)*7),oy+Math.round(Math.sin(a*3)),Math.round(Z+Math.sin(a)*7),i%9===0?CRYSTAL:GOLD)}
|
|
// Giant crescent standing in the meridian plane.
|
|
for(let y=-7;y<=7;y++)for(let x=-7;x<=7;x++){const outer=x*x+y*y<=49,cut=(x-3)*(x-3)+y*y<40;if(outer&&!cut)put(chunk,X+x,B+9+y,Z,GOLD)}
|
|
sphere(chunk,X-1,B+9,Z,1.2,CRYSTAL,'air');
|
|
// Four reflecting pools rendered as star glass mosaics.
|
|
for(const [dx,dz] of [[-5,-5],[5,-5],[-5,5],[5,5]])for(let z=-2;z<=2;z++)for(let x=-2;x<=2;x++)if(x*x+z*z<5)put(chunk,X+dx+x,B+1,Z+dz+z,CRYSTAL);
|
|
}
|
|
|
|
function buildDragon(chunk){
|
|
const B=dragonBase;if(!overlaps(chunk,12,55,-58,-34))return;
|
|
// Curving vertebrae and ribs form a walkable fossil across the valley.
|
|
const spine=[];for(let i=0;i<=33;i++){const x=16+i,z=-47+Math.round(Math.sin(i*.25)*2),y=B+Math.round(Math.sin(i*.18)*2);spine.push([x,y,z]);sphere(chunk,x,y,z,.85,i%5===0?CRYSTAL:RUNE)}
|
|
for(let i=0;i<spine.length-1;i++)lineBlocks(chunk,spine[i],spine[i+1],RUNE,.62);
|
|
for(let i=3;i<28;i+=4){const [x,y,z]=spine[i];for(const side of [-1,1]){let prev=[x,y,z];for(let k=1;k<=7;k++){const a=k/7*Math.PI*.92,next=[x,y+Math.round(Math.sin(a)*5),z+side*Math.round(k*1.12)];lineBlocks(chunk,prev,next,k===7?GOLD:RUNE,.52);prev=next}}}
|
|
// Horned skull at the eastern end.
|
|
sphere(chunk,51,B+1,-46,3.3,RUNE);box(chunk,51,B,-48,56,B+2,-44,RUNE);box(chunk,53,B+1,-47,57,B+1,-45,GOLD);put(chunk,53,B+2,-48,CRYSTAL);put(chunk,53,B+2,-44,CRYSTAL);
|
|
lineBlocks(chunk,[50,B+3,-48],[47,B+8,-53],OBSIDIAN,.65);lineBlocks(chunk,[50,B+3,-44],[47,B+8,-39],OBSIDIAN,.65);
|
|
// Tail tapers into the ground.
|
|
lineBlocks(chunk,[16,B,-47],[9,heightAt(9,-49)+1,-49],GOLD,.48);
|
|
}
|
|
|
|
function buildFloatingIsland(chunk){
|
|
const X=5,Z=48,Y=40;if(!overlaps(chunk,X-17,X+17,Z-14,Z+14))return;
|
|
// Main island: grassy crown and tapered stone underside.
|
|
for(let y=Y-11;y<=Y;y++)for(let z=Z-9;z<=Z+9;z++)for(let x=X-12;x<=X+12;x++){
|
|
const ny=(Y-y)/11,rx=12*(1-ny*.72),rz=9*(1-ny*.72),d=((x-X)/rx)**2+((z-Z)/rz)**2;
|
|
if(d<=1)put(chunk,x,y,z,y===Y?GRASS:y>Y-3?DIRT:(x+z+y)%11===0?RUNE:STONE);
|
|
}
|
|
// Chained anchors descend toward the world below.
|
|
for(const [dx,dz] of [[-7,-5],[7,-5],[-7,5],[7,5]])for(let y=Y-10;y>=9;y--)if(y%3!==1)put(chunk,X+dx+(y%4===0?1:0),y,Z+dz,OBSIDIAN);
|
|
// Ruined sky keep, bridge, beacon and satellite isles.
|
|
box(chunk,X-6,Y+1,Z-5,X+6,Y+1,Z+5,GOLD);for(const [dx,dz] of [[-6,-5],[6,-5],[-6,5],[6,5]]){box(chunk,X+dx,Y+2,Z+dz,X+dx,Y+10,Z+dz,RUNE);sphere(chunk,X+dx,Y+11,Z+dz,1.2,CRYSTAL,'air')}
|
|
box(chunk,X-6,Y+8,Z-5,X+6,Y+8,Z-5,GOLD);box(chunk,X-6,Y+8,Z+5,X+6,Y+8,Z+5,GOLD);lineBlocks(chunk,[X,Y+2,Z],[X,Y+13,Z],CRYSTAL,.62);
|
|
for(const [dx,dz,yy,r] of [[14,1,Y-3,4],[-15,-2,Y+4,3],[2,13,Y+1,3]])for(let y=yy-4;y<=yy;y++)for(let z=-r;z<=r;z++)for(let x=-r;x<=r;x++)if(x*x+z*z<=(r-(yy-y)*.45)**2)put(chunk,X+dx+x,y,Z+dz+z,y===yy?GRASS:STONE);
|
|
}
|
|
|
|
function generateChunk(cx,cz){
|
|
const chunk={cx,cz,data:new Uint8Array(CHUNK*WORLD_H*CHUNK),mesh:null,dirty:true};
|
|
for(let lz=0;lz<CHUNK;lz++)for(let lx=0;lx<CHUNK;lx++){const x=cx*CHUNK+lx,z=cz*CHUNK+lz,h=heightAt(x,z);for(let y=0;y<=h;y++)chunk.data[voxelIndex(lx,y,lz)]=terrainBlockAt(x,y,z)}
|
|
decorateNature(chunk);buildWanderingSpires(chunk);buildTemple(chunk);buildWorldTree(chunk);buildMoonShrine(chunk);buildDragon(chunk);buildFloatingIsland(chunk);
|
|
const stored=editsByChunk.get(chunkKey(cx,cz));if(stored)for(const [idx,type] of stored)chunk.data[idx]=type;
|
|
return chunk;
|
|
}
|
|
|
|
const FACES=[
|
|
{n:[1,0,0], side:'side', shade:.87, v:[[1,0,0],[1,1,0],[1,1,1],[1,0,1]]},
|
|
{n:[-1,0,0],side:'side', shade:.73, v:[[0,0,1],[0,1,1],[0,1,0],[0,0,0]]},
|
|
{n:[0,1,0], side:'top', shade:1, v:[[0,1,1],[1,1,1],[1,1,0],[0,1,0]]},
|
|
{n:[0,-1,0],side:'bottom',shade:.54,v:[[0,0,0],[1,0,0],[1,0,1],[0,0,1]]},
|
|
{n:[0,0,1], side:'side', shade:.81,v:[[1,0,1],[1,1,1],[0,1,1],[0,0,1]]},
|
|
{n:[0,0,-1],side:'side', shade:.68,v:[[0,0,0],[0,1,0],[1,1,0],[1,0,0]]}
|
|
];
|
|
const meshGroup=new THREE.Group();meshGroup.name='Voxel world';scene.add(meshGroup);
|
|
function getVoxel(x,y,z){
|
|
x=Math.floor(x);y=Math.floor(y);z=Math.floor(z);if(y<0||y>=WORLD_H)return AIR;
|
|
const cx=floorDiv(x,CHUNK),cz=floorDiv(z,CHUNK),chunk=chunks.get(chunkKey(cx,cz));
|
|
if(chunk)return chunk.data[voxelIndex(mod(x,CHUNK),y,mod(z,CHUNK))];
|
|
return terrainBlockAt(x,y,z);
|
|
}
|
|
function buildChunkMesh(chunk){
|
|
const pos=[],normal=[],uv=[],color=[],indices=[];let vi=0;
|
|
for(let y=0;y<WORLD_H;y++)for(let lz=0;lz<CHUNK;lz++)for(let lx=0;lx<CHUNK;lx++){
|
|
const type=chunk.data[voxelIndex(lx,y,lz)];if(type===AIR)continue;const def=BLOCKS[type],wx=chunk.cx*CHUNK+lx,wz=chunk.cz*CHUNK+lz;
|
|
for(const face of FACES){if(getVoxel(wx+face.n[0],y+face.n[1],wz+face.n[2])!==AIR)continue;
|
|
const tile=def[face.side],tx=tile%4,ty=Math.floor(tile/4),pad=.0018,u0=tx/4+pad,u1=(tx+1)/4-pad,v0=1-(ty+1)/4+pad,v1=1-ty/4-pad;
|
|
const uvs=[[u0,v0],[u0,v1],[u1,v1],[u1,v0]],tint=def.tint;
|
|
for(let k=0;k<4;k++){const p=face.v[k];pos.push(lx+p[0],y+p[1],lz+p[2]);normal.push(...face.n);uv.push(...uvs[k]);const sparkle=type===CRYSTAL?1.12:1;color.push(clamp(tint[0]*face.shade*sparkle,0,1),clamp(tint[1]*face.shade*sparkle,0,1),clamp(tint[2]*face.shade*sparkle,0,1))}
|
|
indices.push(vi,vi+1,vi+2,vi,vi+2,vi+3);vi+=4;
|
|
}
|
|
}
|
|
const geo=new THREE.BufferGeometry();geo.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));geo.setAttribute('normal',new THREE.Float32BufferAttribute(normal,3));geo.setAttribute('uv',new THREE.Float32BufferAttribute(uv,2));geo.setAttribute('color',new THREE.Float32BufferAttribute(color,3));geo.setIndex(indices);geo.computeBoundingSphere();
|
|
const mesh=new THREE.Mesh(geo,worldMaterial);mesh.position.set(chunk.cx*CHUNK,0,chunk.cz*CHUNK);mesh.userData.chunk=chunk;mesh.matrixAutoUpdate=false;mesh.updateMatrix();
|
|
if(chunk.mesh){meshGroup.remove(chunk.mesh);chunk.mesh.geometry.dispose()}chunk.mesh=mesh;meshGroup.add(mesh);chunk.dirty=false;
|
|
}
|
|
function rebuildChunk(cx,cz){const ch=chunks.get(chunkKey(cx,cz));if(ch)buildChunkMesh(ch)}
|
|
const queued=new Set();
|
|
function queueChunk(cx,cz,priority=false){const k=chunkKey(cx,cz);if(chunks.has(k)||queued.has(k))return;queued.add(k);priority?loadQueue.unshift([cx,cz]):loadQueue.push([cx,cz])}
|
|
function loadChunk(cx,cz){const k=chunkKey(cx,cz);if(chunks.has(k))return;const chunk=generateChunk(cx,cz);chunks.set(k,chunk);buildChunkMesh(chunk);queued.delete(k)}
|
|
function queueNeighborhood(x,z){
|
|
const cx=floorDiv(x,CHUNK),cz=floorDiv(z,CHUNK),todo=[];
|
|
for(let dz=-VIEW_RADIUS;dz<=VIEW_RADIUS;dz++)for(let dx=-VIEW_RADIUS;dx<=VIEW_RADIUS;dx++)if(dx*dx+dz*dz<=(VIEW_RADIUS+.35)**2)todo.push([cx+dx,cz+dz,dx*dx+dz*dz]);
|
|
todo.sort((a,b)=>a[2]-b[2]);for(const q of todo)queueChunk(q[0],q[1]);
|
|
for(const [k,ch] of chunks){if(Math.max(Math.abs(ch.cx-cx),Math.abs(ch.cz-cz))>VIEW_RADIUS+2){meshGroup.remove(ch.mesh);ch.mesh.geometry.dispose();chunks.delete(k)}}
|
|
}
|
|
function processLoadQueue(){
|
|
const start=performance.now();while(loadQueue.length&&performance.now()-start<9){const q=loadQueue.shift();if(!chunks.has(chunkKey(q[0],q[1])))loadChunk(q[0],q[1]);else queued.delete(chunkKey(q[0],q[1]))}
|
|
}
|
|
function setVoxel(x,y,z,type){
|
|
x=Math.floor(x);y=Math.floor(y);z=Math.floor(z);if(y<=0||y>=WORLD_H)return false;
|
|
const cx=floorDiv(x,CHUNK),cz=floorDiv(z,CHUNK),k=chunkKey(cx,cz),idx=voxelIndex(mod(x,CHUNK),y,mod(z,CHUNK));let edit=editsByChunk.get(k);if(!edit){edit=new Map();editsByChunk.set(k,edit)}edit.set(idx,type);
|
|
const chunk=chunks.get(k);if(!chunk)return false;chunk.data[idx]=type;rebuildChunk(cx,cz);
|
|
if(mod(x,CHUNK)===0)rebuildChunk(cx-1,cz);if(mod(x,CHUNK)===CHUNK-1)rebuildChunk(cx+1,cz);if(mod(z,CHUNK)===0)rebuildChunk(cx,cz-1);if(mod(z,CHUNK)===CHUNK-1)rebuildChunk(cx,cz+1);queueSave();return true;
|
|
}
|
|
|
|
const selection=new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(1.012,1.012,1.012)),new THREE.LineBasicMaterial({color:0xffe3a0,transparent:true,opacity:.9,depthTest:false}));selection.visible=false;selection.renderOrder=5;scene.add(selection);
|
|
const raycaster=new THREE.Raycaster();raycaster.far=7.5;const centerNdc=new THREE.Vector2(0,0);let currentHit=null;
|
|
function traceTarget(){
|
|
raycaster.setFromCamera(centerNdc,camera);const hits=raycaster.intersectObjects(meshGroup.children,false);currentHit=null;
|
|
for(const hit of hits){if(hit.distance>7.5||!hit.face)continue;const n=hit.face.normal,breakPos=hit.point.clone().addScaledVector(n,-.012),placePos=hit.point.clone().addScaledVector(n,.012);currentHit={hit,normal:n.clone(),break:{x:Math.floor(breakPos.x),y:Math.floor(breakPos.y),z:Math.floor(breakPos.z)},place:{x:Math.floor(placePos.x),y:Math.floor(placePos.y),z:Math.floor(placePos.z)}};break}
|
|
if(currentHit){const p=currentHit.break,type=getVoxel(p.x,p.y,p.z);selection.visible=true;selection.position.set(p.x+.5,p.y+.5,p.z+.5);targetText.textContent=`${BLOCKS[type]?.name||'未知方块'} · 左键采集 / 右键放置`;}
|
|
else{selection.visible=false;targetText.textContent='凝视方块以采集'}
|
|
}
|
|
|
|
let saveTimer=0;
|
|
function restoreEdits(){try{const raw=localStorage.getItem('mythos-craft-edits-v1');if(!raw)return;for(const [k,entries] of JSON.parse(raw))editsByChunk.set(k,new Map(entries))}catch(e){console.warn('存档读取失败',e)}}
|
|
function queueSave(){clearTimeout(saveTimer);saveTimer=setTimeout(()=>{try{localStorage.setItem('mythos-craft-edits-v1',JSON.stringify([...editsByChunk].map(([k,v])=>[k,[...v]])))}catch(e){}},600)}
|
|
restoreEdits();
|
|
|
|
let selectedBlock=RUNE, toastTimer=0;
|
|
function buildHotbar(){
|
|
hotbar.innerHTML='';for(let i=1;i<=9;i++){const d=BLOCKS[i],slot=document.createElement('div');slot.className='slot'+(i===selectedBlock?' active':'');slot.dataset.block=i;slot.innerHTML=`<span class="key">${i}</span><i class="cube" style="background:${d.css}"></i><span class="name">${d.short}</span>`;hotbar.appendChild(slot)}
|
|
}
|
|
function selectBlock(n,announce=true){selectedBlock=clamp(n,1,9);for(const el of hotbar.children)el.classList.toggle('active',+el.dataset.block===selectedBlock);if(announce)showToast(`已选择 · ${BLOCKS[selectedBlock].name}`)}
|
|
function showToast(text){toastEl.textContent=text;toastEl.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>toastEl.classList.remove('show'),1500)}
|
|
buildHotbar();
|
|
|
|
const player={pos:new THREE.Vector3(.5,20,8.5),vel:new THREE.Vector3(),radius:.29,eye:1.62,head:.18,onGround:false,stamina:100};
|
|
const spawn={x:.5,z:8.5,y:20};let yaw=0,pitch=-.04;
|
|
function highestSolid(x,z){for(let y=WORLD_H-2;y>=1;y--)if(getVoxel(x,y,z)!==AIR)return y;return heightAt(x,z)}
|
|
function respawn(silent=false){const y=highestSolid(spawn.x,spawn.z)+1+player.eye+.03;player.pos.set(spawn.x,y,spawn.z);player.vel.set(0,0,0);yaw=0;pitch=-.05;if(!silent)showToast('星印将你送回日冕古道')}
|
|
function playerCollides(pos){
|
|
const e=.001,minX=Math.floor(pos.x-player.radius+e),maxX=Math.floor(pos.x+player.radius-e),minY=Math.floor(pos.y-player.eye+e),maxY=Math.floor(pos.y+player.head-e),minZ=Math.floor(pos.z-player.radius+e),maxZ=Math.floor(pos.z+player.radius-e);
|
|
for(let y=minY;y<=maxY;y++)for(let z=minZ;z<=maxZ;z++)for(let x=minX;x<=maxX;x++)if(getVoxel(x,y,z)!==AIR)return true;return false;
|
|
}
|
|
function moveAxis(axis,delta){if(!delta)return;const steps=Math.max(1,Math.ceil(Math.abs(delta)/.16)),d=delta/steps;for(let i=0;i<steps;i++){player.pos[axis]+=d;if(playerCollides(player.pos)){player.pos[axis]-=d;player.vel[axis]=0;if(axis==='y'&&d<0)player.onGround=true;break}}}
|
|
const keys=new Set();let locked=false,started=false;
|
|
function updatePlayer(dt){
|
|
if(!locked){camera.position.copy(player.pos);return}
|
|
const f=(keys.has('KeyW')?1:0)-(keys.has('KeyS')?1:0),s=(keys.has('KeyD')?1:0)-(keys.has('KeyA')?1:0),mag=Math.hypot(f,s)||1;
|
|
const sprint=keys.has('ShiftLeft')&&f>0&&player.stamina>1,speed=sprint?7.5:4.65,tx=(-Math.sin(yaw)*f+Math.cos(yaw)*s)/mag*speed,tz=(-Math.cos(yaw)*f-Math.sin(yaw)*s)/mag*speed;
|
|
const response=1-Math.exp(-(player.onGround?13:4.5)*dt);player.vel.x+=(tx-player.vel.x)*response;player.vel.z+=(tz-player.vel.z)*response;
|
|
if(f===0&&s===0){const drag=Math.exp(-(player.onGround?15:2)*dt);player.vel.x*=drag;player.vel.z*=drag}
|
|
if(sprint&&(f||s))player.stamina=Math.max(0,player.stamina-dt*22);else player.stamina=Math.min(100,player.stamina+dt*14);
|
|
player.vel.y-=27*dt;player.onGround=false;moveAxis('x',player.vel.x*dt);moveAxis('z',player.vel.z*dt);moveAxis('y',player.vel.y*dt);
|
|
if(player.pos.y<-8)respawn();camera.position.copy(player.pos);camera.rotation.x=pitch;camera.rotation.y=yaw;
|
|
$('#stamina').style.width=player.stamina+'%';$('#staminaText').textContent=Math.round(player.stamina);
|
|
}
|
|
function blockIntersectsPlayer(x,y,z){const minX=player.pos.x-player.radius,maxX=player.pos.x+player.radius,minY=player.pos.y-player.eye,maxY=player.pos.y+player.head,minZ=player.pos.z-player.radius,maxZ=player.pos.z+player.radius;return x<maxX&&x+1>minX&&y<maxY&&y+1>minY&&z<maxZ&&z+1>minZ}
|
|
|
|
let audioCtx=null,lastAction=0;const particles=[];
|
|
function initAudio(){if(!audioCtx)audioCtx=new (window.AudioContext||window.webkitAudioContext)();if(audioCtx.state==='suspended')audioCtx.resume()}
|
|
function tone(freq,dur=.08,type='triangle',vol=.025,slide=0){if(!audioCtx)return;const t=audioCtx.currentTime,o=audioCtx.createOscillator(),g=audioCtx.createGain();o.type=type;o.frequency.setValueAtTime(freq,t);o.frequency.exponentialRampToValueAtTime(Math.max(20,freq+slide),t+dur);g.gain.setValueAtTime(vol,t);g.gain.exponentialRampToValueAtTime(.0001,t+dur);o.connect(g).connect(audioCtx.destination);o.start(t);o.stop(t+dur)}
|
|
function burst(x,y,z,type){
|
|
const color=new THREE.Color(BLOCKS[type]?.css||'#aaa');for(let i=0;i<7;i++){const mesh=new THREE.Mesh(new THREE.BoxGeometry(.11,.11,.11),new THREE.MeshBasicMaterial({color}));mesh.position.set(x+.5+(Math.random()-.5)*.4,y+.5+(Math.random()-.5)*.4,z+.5+(Math.random()-.5)*.4);scene.add(mesh);particles.push({mesh,life:.48,vel:new THREE.Vector3((Math.random()-.5)*2.5,1+Math.random()*2,(Math.random()-.5)*2.5)})}
|
|
}
|
|
function updateParticles(dt){for(let i=particles.length-1;i>=0;i--){const p=particles[i];p.life-=dt;p.vel.y-=7*dt;p.mesh.position.addScaledVector(p.vel,dt);p.mesh.rotation.x+=dt*5;p.mesh.rotation.y+=dt*4;p.mesh.material.opacity=Math.max(0,p.life*2);p.mesh.material.transparent=true;if(p.life<=0){scene.remove(p.mesh);p.mesh.geometry.dispose();p.mesh.material.dispose();particles.splice(i,1)}}}
|
|
function useTool(button){
|
|
if(!locked||performance.now()-lastAction<145)return;lastAction=performance.now();traceTarget();if(!currentHit)return;
|
|
if(button===0){const p=currentHit.break,type=getVoxel(p.x,p.y,p.z);if(type!==AIR&&p.y>0&&setVoxel(p.x,p.y,p.z,AIR)){burst(p.x,p.y,p.z,type);tone(150,.095,'square',.018,-90);showToast(`采集 · ${BLOCKS[type].name}`)}}
|
|
if(button===2){const p=currentHit.place;if(p.y>0&&p.y<WORLD_H&&getVoxel(p.x,p.y,p.z)===AIR&&!blockIntersectsPlayer(p.x,p.y,p.z)&&setVoxel(p.x,p.y,p.z,selectedBlock)){tone(105,.07,'triangle',.025,45);showToast(`放置 · ${BLOCKS[selectedBlock].name}`)}}
|
|
}
|
|
|
|
document.addEventListener('keydown',e=>{
|
|
keys.add(e.code);if(locked&&['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code))e.preventDefault();
|
|
if(e.code==='Space'&&locked&&player.onGround){player.vel.y=8.8;player.onGround=false;tone(170,.06,'sine',.014,40)}
|
|
if(/^Digit[1-9]$/.test(e.code))selectBlock(+e.code.slice(5));if(e.code==='KeyR')respawn();
|
|
});
|
|
document.addEventListener('keyup',e=>keys.delete(e.code));
|
|
document.addEventListener('mousemove',e=>{if(!locked)return;yaw-=e.movementX*.00215;pitch=clamp(pitch-e.movementY*.00215,-Math.PI/2+.015,Math.PI/2-.015)});
|
|
document.addEventListener('mousedown',e=>useTool(e.button));document.addEventListener('contextmenu',e=>e.preventDefault());
|
|
document.addEventListener('wheel',e=>{if(!locked)return;selectBlock(mod(selectedBlock-1+(e.deltaY>0?1:-1),9)+1);e.preventDefault()},{passive:false});
|
|
$('#startBtn').addEventListener('click',()=>{initAudio();renderer.domElement.requestPointerLock()});
|
|
document.addEventListener('pointerlockchange',()=>{locked=document.pointerLockElement===renderer.domElement;keys.clear();if(locked){started=true;veil.classList.add('hidden');hud.classList.remove('hidden');showToast('向北沿古道寻找日冕圣门')}else if(started){veil.classList.remove('hidden');$('#startBtn').textContent='继 续 探 索'}});
|
|
|
|
function makeAtmosphere(){
|
|
const rnd=mulberry32(SEED^0x7799),motePos=[];for(let i=0;i<180;i++)motePos.push((rnd()-.5)*50,(rnd()-.5)*28,(rnd()-.5)*50);
|
|
const geo=new THREE.BufferGeometry();geo.setAttribute('position',new THREE.Float32BufferAttribute(motePos,3));const motes=new THREE.Points(geo,new THREE.PointsMaterial({color:0x92f7dc,size:.105,transparent:true,opacity:.62,depthWrite:false,blending:THREE.AdditiveBlending}));scene.add(motes);
|
|
const clouds=new THREE.Group(),mat=new THREE.MeshBasicMaterial({color:0xcde4dd,transparent:true,opacity:.07,depthWrite:false});
|
|
for(let i=0;i<18;i++){const m=new THREE.Mesh(new THREE.BoxGeometry(10+rnd()*18,.7+rnd()*1.4,4+rnd()*9),mat);m.position.set((rnd()-.5)*170,38+rnd()*20,(rnd()-.5)*170);m.rotation.y=rnd()*.5;clouds.add(m)}scene.add(clouds);return {motes,clouds};
|
|
}
|
|
const atmosphere=makeAtmosphere();let dayPhase=.18;
|
|
const dawnColor=new THREE.Color(0xa66b58),dayColor=new THREE.Color(0x5e9aaa),nightColor=new THREE.Color(0x07121f),skyColor=new THREE.Color();
|
|
function updateSky(dt,time){
|
|
dayPhase=(dayPhase+dt*.00125)%1;const a=dayPhase*Math.PI*2,sunUp=clamp(Math.sin(a)*1.7+.14,0,1),twilight=clamp(1-Math.abs(Math.sin(a))*3.2,0,1);
|
|
skyColor.copy(nightColor).lerp(dayColor,sunUp).lerp(dawnColor,twilight*.48);scene.background.copy(skyColor);scene.fog.color.copy(skyColor);
|
|
const sx=Math.cos(a)*115,sy=Math.sin(a)*105,sz=42;sun.position.set(player.pos.x+sx,sy,player.pos.z+sz);sun.target.position.set(player.pos.x,0,player.pos.z);sun.intensity=.12+sunUp*2.25;hemi.intensity=.32+sunUp*1.05;
|
|
sky.sunDisc.position.set(player.pos.x+sx,sy,player.pos.z+sz);sky.sunDisc.visible=sy>-12;sky.moonDisc.position.set(player.pos.x-sx*.82,-sy*.75+18,player.pos.z-sz*1.8);sky.moonDisc.visible=sy<30;
|
|
sky.stars.position.set(player.pos.x,0,player.pos.z);sky.stars.material.opacity=.12+(1-sunUp)*.75;sky.stars.rotation.y=time*.000008;
|
|
atmosphere.motes.position.set(player.pos.x,player.pos.y,player.pos.z);atmosphere.motes.rotation.y=time*.000025;atmosphere.motes.position.y+=Math.sin(time*.0004)*1.5;
|
|
atmosphere.clouds.position.x=time*.00032;const wrap=180;if(atmosphere.clouds.position.x-player.pos.x>wrap)atmosphere.clouds.position.x-=wrap*2;if(atmosphere.clouds.position.x-player.pos.x<-wrap)atmosphere.clouds.position.x+=wrap*2;
|
|
daytimeEl.textContent=sy>55?'曜日':sy>8?(Math.cos(a)>0?'晨曦':'暮色'):sy>-18?'星昏':'月夜';
|
|
}
|
|
|
|
function updateWorldReadout(){
|
|
const x=Math.floor(player.pos.x),y=Math.floor(player.pos.y-player.eye),z=Math.floor(player.pos.z),bio=biomeAt(x,z);coordsEl.textContent=`${x} / ${y} / ${z}`;biomeEl.textContent=bio.name;
|
|
const dir=mod(Math.round((-yaw)/(Math.PI/4)),8),names=['N','NE','E','SE','S','SW','W','NW'];$('#heading').textContent=names[dir];
|
|
let nearest=LANDMARKS[0],dist=Infinity;for(const m of LANDMARKS){const d=Math.hypot(player.pos.x-m.x,player.pos.z-m.z);if(d<dist){nearest=m;dist=d}}
|
|
$('#objName').textContent=nearest.name;$('#objCopy').textContent=nearest.copy;$('#objDistance').textContent=dist<9?'你已抵达此处':`距离 ${Math.round(dist)} 格 · 朝 ${Math.abs(nearest.x-player.pos.x)>Math.abs(nearest.z-player.pos.z)?nearest.x>player.pos.x?'东':'西':nearest.z>player.pos.z?'南':'北'}`;
|
|
$('#debug').textContent=`区块 ${floorDiv(x,CHUNK)}, ${floorDiv(z,CHUNK)} · 已载入 ${chunks.size} · 地形队列 ${loadQueue.length}`;
|
|
}
|
|
function drawMinimap(){
|
|
const W=mapCanvas.width,c=W/2,scale=2.18,step=6;mapCtx.clearRect(0,0,W,W);mapCtx.save();mapCtx.beginPath();mapCtx.arc(c,c,c-2,0,Math.PI*2);mapCtx.clip();mapCtx.fillStyle='#071216';mapCtx.fillRect(0,0,W,W);
|
|
for(let py=-c;py<c;py+=step)for(let px=-c;px<c;px+=step){if(px*px+py*py>c*c)continue;const wx=Math.floor(player.pos.x+px/scale),wz=Math.floor(player.pos.z+py/scale),h=heightAt(wx,wz),bio=biomeAt(wx,wz);mapCtx.fillStyle=h>24?'#77817e':bio.color;mapCtx.globalAlpha=.55+Math.min(.35,h/70);mapCtx.fillRect(c+px,c+py,step+1,step+1)}mapCtx.globalAlpha=1;
|
|
mapCtx.strokeStyle='rgba(244,217,150,.18)';mapCtx.lineWidth=1;for(const r of [46,92,136]){mapCtx.beginPath();mapCtx.arc(c,c,r,0,Math.PI*2);mapCtx.stroke()}mapCtx.beginPath();mapCtx.moveTo(c,3);mapCtx.lineTo(c,W-3);mapCtx.moveTo(3,c);mapCtx.lineTo(W-3,c);mapCtx.stroke();
|
|
for(const m of LANDMARKS){let dx=(m.x-player.pos.x)*scale,dz=(m.z-player.pos.z)*scale,rr=Math.hypot(dx,dz);if(rr>c-11){dx*= (c-11)/rr;dz*=(c-11)/rr}mapCtx.save();mapCtx.translate(c+dx,c+dz);mapCtx.rotate(Math.PI/4);mapCtx.fillStyle=m.name==='日冕圣门'?'#f1c66c':'#76ead5';mapCtx.shadowColor=mapCtx.fillStyle;mapCtx.shadowBlur=7;mapCtx.fillRect(-4,-4,8,8);mapCtx.restore()}
|
|
mapCtx.save();mapCtx.translate(c,c);mapCtx.rotate(-yaw);mapCtx.fillStyle='#fff5cf';mapCtx.shadowColor='#fff';mapCtx.shadowBlur=6;mapCtx.beginPath();mapCtx.moveTo(0,-10);mapCtx.lineTo(6,7);mapCtx.lineTo(0,4);mapCtx.lineTo(-6,7);mapCtx.closePath();mapCtx.fill();mapCtx.restore();mapCtx.restore();
|
|
}
|
|
|
|
// The spawn ring is loaded synchronously; the wider world streams in while the title screen is visible.
|
|
for(let dz=-1;dz<=1;dz++)for(let dx=-1;dx<=1;dx++)loadChunk(dx,dz);
|
|
queueNeighborhood(spawn.x,spawn.z);respawn(true);camera.position.copy(player.pos);scene.add(sun.target);
|
|
loading.classList.add('hidden');
|
|
let last=performance.now(),frame=0,maintenance=0,mapTimer=0;
|
|
function animate(now){
|
|
requestAnimationFrame(animate);const dt=Math.min(.045,(now-last)/1000||0);last=now;frame++;processLoadQueue();updatePlayer(dt);updateParticles(dt);updateSky(dt,now);
|
|
maintenance+=dt;mapTimer+=dt;if(maintenance>.8){maintenance=0;queueNeighborhood(player.pos.x,player.pos.z);updateWorldReadout()}if(mapTimer>.65){mapTimer=0;drawMinimap()}if(locked&&frame%3===0)traceTarget();
|
|
renderer.render(scene,camera);
|
|
}
|
|
updateWorldReadout();drawMinimap();requestAnimationFrame(animate);
|
|
addEventListener('resize',()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setPixelRatio(Math.min(devicePixelRatio,1.65));renderer.setSize(innerWidth,innerHeight)});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|