<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>MineJS — Minecraft Clone</title> <link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet"> <style> *{margin:0;padding:0;box-sizing:border-box;user-select:none} html,body{width:100%;height:100%;overflow:hidden;background:#000;font-family:'Press Start 2P',monospace} canvas{display:block} #gameCanvas{position:absolute;inset:0} .pixel{image-rendering:pixelated;image-rendering:crisp-edges} /* ---------- HUD ---------- */ #hud{position:absolute;inset:0;pointer-events:none;display:none} #crosshair{position:absolute;left:50%;top:50%;width:20px;height:20px;transform:translate(-50%,-50%);mix-blend-mode:difference} #crosshair:before,#crosshair:after{content:'';position:absolute;background:#fff} #crosshair:before{left:9px;top:0;width:2px;height:20px} #crosshair:after{top:9px;left:0;width:20px;height:2px} #hotbar{position:absolute;bottom:8px;left:50%;transform:translateX(-50%);display:flex;gap:0;background:rgba(0,0,0,.45);border:2px solid #1a1a1a;outline:2px solid rgba(255,255,255,.25)} .slot{width:46px;height:46px;border:2px solid #555;background:rgba(40,40,40,.6);display:flex;align-items:center;justify-content:center;position:relative} .slot.sel{border:2px solid #fff;background:rgba(90,90,90,.7);box-shadow:0 0 6px rgba(255,255,255,.6) inset} .slot canvas{width:36px;height:36px} #hearts{position:absolute;bottom:62px;left:50%;transform:translateX(-50%);font-size:15px;letter-spacing:2px;text-shadow:2px 2px 0 #000;font-family:Arial} #itemname{position:absolute;bottom:92px;left:50%;transform:translateX(-50%);color:#fff;font-size:10px;text-shadow:2px 2px #000;opacity:0;transition:opacity .5s} #debug{position:absolute;top:6px;left:6px;color:#fff;font-size:8px;line-height:1.8;text-shadow:1px 1px #000;background:rgba(0,0,0,.25);padding:6px} #vignette{position:absolute;inset:0;background:radial-gradient(ellipse at center,transparent 55%,rgba(0,0,0,.35) 100%)} #damageFlash{position:absolute;inset:0;background:rgba(255,0,0,.35);opacity:0;transition:opacity .4s} #waterOverlay{position:absolute;inset:0;background:rgba(20,60,160,.35);display:none} /* ---------- Screens ---------- */ .screen{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#fff;text-align:center;z-index:10} #titleScreen{background:linear-gradient(#3a7bd5 0%,#79a7e8 45%,#3b7a2a 45.2%,#2a5c1e 100%)} #titleScreen:before{content:'';position:absolute;inset:0;background:repeating-conic-gradient(rgba(0,0,0,.06) 0 25%,transparent 0 50%) 0 0/32px 32px;opacity:.6} h1{font-size:42px;color:#fff;text-shadow:4px 4px 0 #3f3f3f, 8px 8px 0 rgba(0,0,0,.3);margin-bottom:8px;letter-spacing:4px;position:relative} .sub{color:#ffff55;font-size:11px;text-shadow:2px 2px #3f3f00;margin-bottom:34px;transform:rotate(-4deg);animation:pulse 1s infinite;position:relative} @keyframes pulse{50%{transform:rotate(-4deg) scale(1.08)}} .btn{font-family:inherit;font-size:12px;color:#fff;background:#6f6f6f;border:2px solid #000;box-shadow:inset 2px 2px 0 rgba(255,255,255,.45),inset -2px -2px 0 rgba(0,0,0,.45);padding:14px 40px;margin:6px;cursor:pointer;position:relative} .btn:hover{background:#7f8fbf} .controls{font-size:8px;line-height:2.2;color:#ddd;margin-top:28px;text-shadow:1px 1px #000;position:relative} #pauseScreen,#deathScreen{background:rgba(0,0,0,.55);display:none} #deathScreen{background:rgba(120,0,0,.5)} #invScreen{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);background:#c6c6c6;border:4px solid #555;box-shadow:inset 3px 3px 0 #fff,inset -3px -3px 0 #888,0 0 0 3px #000;padding:14px;display:none;z-index:20} #invScreen h3{font-size:10px;color:#404040;margin-bottom:10px} #invGrid{display:grid;grid-template-columns:repeat(9,44px);gap:2px} .invSlot{width:44px;height:44px;background:#8b8b8b;box-shadow:inset 2px 2px 0 #373737,inset -2px -2px 0 #fff;display:flex;align-items:center;justify-content:center;cursor:pointer} .invSlot:hover{background:#a8a8c0} .invSlot canvas{width:32px;height:32px} #loadingText{font-size:10px;margin-top:20px;color:#fff;text-shadow:2px 2px #000;position:relative} </style> </head> <body> <div id="hud"> <div id="vignette"></div> <div id="waterOverlay"></div> <div id="damageFlash"></div> <div id="crosshair"></div> <div id="debug"></div> <div id="hearts"></div> <div id="itemname"></div> <div id="hotbar"></div> </div> <div id="invScreen"><h3>Select Block (E / Esc to close)</h3><div id="invGrid"></div></div> <div id="titleScreen" class="screen"> <h1>MINEJS</h1> <div class="sub">Now in JavaScript!</div> <button class="btn" id="playBtn" disabled>Generating World...</button> <button class="btn" id="newWorldBtn">New World (delete save)</button> <div class="controls"> WASD move SPACE jump SHIFT sneak double-W sprint<br> LMB break RMB place MMB pick block WHEEL/1-9 hotbar<br> E inventory F fly N skip day/night G/H spawn pig/zombie </div> <div id="loadingText"></div> </div> <div id="pauseScreen" class="screen"><h1 style="font-size:24px">PAUSED</h1> <button class="btn" id="resumeBtn">Back to Game</button> <button class="btn" id="resetBtn">Delete World & Restart</button></div> <div id="deathScreen" class="screen"><h1 style="font-size:24px">You Died!</h1> <button class="btn" id="respawnBtn">Respawn</button></div> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> <script> /* ============================================================ MineJS — a Minecraft clone in one file ============================================================ */ 'use strict'; const $=id=>document.getElementById(id); const clamp=(v,a,b)=>v<a?a:v>b?b:v; const lerp=(a,b,t)=>a+(b-a)*t; const smooth=(a,b,x)=>{const t=clamp((x-a)/(b-a),0,1);return t*t*(3-2*t);}; /* ---------------- RNG + Perlin noise ---------------- */ 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;};} let SEED; function makePerlin(seed){ const p=new Uint8Array(512),perm=[];for(let i=0;i<256;i++)perm[i]=i; const rng=mulberry32(seed); for(let i=255;i>0;i--){const j=(rng()*(i+1))|0;[perm[i],perm[j]]=[perm[j],perm[i]];} for(let i=0;i<512;i++)p[i]=perm[i&255]; const fade=t=>t*t*t*(t*(t*6-15)+10); const grad=(h,x,y,z)=>{const u=h<8?x:y,v=h<4?y:(h===12||h===14?x:z);return((h&1)?-u:u)+((h&2)?-v:v);}; function n3(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=fade(x),v=fade(y),w=fade(z); 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 lerp(lerp(lerp(grad(p[AA]&15,x,y,z),grad(p[BA]&15,x-1,y,z),u), lerp(grad(p[AB]&15,x,y-1,z),grad(p[BB]&15,x-1,y-1,z),u),v), lerp(lerp(grad(p[AA+1]&15,x,y,z-1),grad(p[BA+1]&15,x-1,y,z-1),u), lerp(grad(p[AB+1]&15,x,y-1,z-1),grad(p[BB+1]&15,x-1,y-1,z-1),u),v),w); } return{n3,n2:(x,y)=>n3(x,y,0)}; } let perlin; function fbm2(x,y,oct){let v=0,a=1,f=1,m=0;for(let i=0;i<oct;i++){v+=perlin.n2(x*f,y*f)*a;m+=a;a*=.5;f*=2;}return v/m;} function h3(x,y,z){let n=(x|0)*73856093^(y|0)*19349663^(z|0)*83492791^SEED;n=Math.imul(n^(n>>>13),1274126177);n^=n>>>16;return(n>>>0)/4294967296;} /* ---------------- Block definitions ---------------- */ const B={AIR:0,GRASS:1,DIRT:2,STONE:3,COBBLE:4,PLANKS:5,LOG:6,LEAVES:7,SAND:8,SANDSTONE:9,GRAVEL:10,BRICK:11,GLASS:12,GLOW:13,SNOWGRASS:14,SNOW:15,WATER:16,COAL:17,IRON:18,GOLD:19,DIAMOND:20,BEDROCK:21,CACTUS:22,FLOWER_R:23,FLOWER_Y:24,TALLGRASS:25}; const T={GRASS_TOP:0,GRASS_SIDE:1,DIRT:2,STONE:3,SAND:4,WATER:5,LOG_SIDE:6,LOG_TOP:7,LEAVES:8,PLANKS:9,COBBLE:10,GLASS:11,COAL:12,IRON:13,GOLD:14,DIAMOND:15,BEDROCK:16,SNOW_TOP:17,SNOW_SIDE:18,GRAVEL:19,BRICK:20,FLOWER_R:21,FLOWER_Y:22,TALLGRASS:23,CACTUS_SIDE:24,CACTUS_TOP:25,SANDSTONE:26,GLOW:27,CRACK0:32}; const D=[];// block defs function def(id,name,top,side,bottom,o){D[id]=Object.assign({name,top,side,bottom,solid:true,transparent:false,cross:false,cullSame:false,hard:1,icon:side},o||{});} def(B.AIR,'Air',0,0,0,{solid:false,transparent:true,hard:0}); def(B.GRASS,'Grass Block',T.GRASS_TOP,T.GRASS_SIDE,T.DIRT,{hard:.6}); def(B.DIRT,'Dirt',T.DIRT,T.DIRT,T.DIRT,{hard:.5}); def(B.STONE,'Stone',T.STONE,T.STONE,T.STONE,{hard:1.5}); def(B.COBBLE,'Cobblestone',T.COBBLE,T.COBBLE,T.COBBLE,{hard:1.6}); def(B.PLANKS,'Oak Planks',T.PLANKS,T.PLANKS,T.PLANKS,{hard:1}); def(B.LOG,'Oak Log',T.LOG_TOP,T.LOG_SIDE,T.LOG_TOP,{hard:1}); def(B.LEAVES,'Oak Leaves',T.LEAVES,T.LEAVES,T.LEAVES,{hard:.25,transparent:true}); def(B.SAND,'Sand',T.SAND,T.SAND,T.SAND,{hard:.5}); def(B.SANDSTONE,'Sandstone',T.SANDSTONE,T.SANDSTONE,T.SANDSTONE,{hard:1.3}); def(B.GRAVEL,'Gravel',T.GRAVEL,T.GRAVEL,T.GRAVEL,{hard:.6}); def(B.BRICK,'Bricks',T.BRICK,T.BRICK,T.BRICK,{hard:1.6}); def(B.GLASS,'Glass',T.GLASS,T.GLASS,T.GLASS,{hard:.3,transparent:true,cullSame:true}); def(B.GLOW,'Glowstone',T.GLOW,T.GLOW,T.GLOW,{hard:.4}); def(B.SNOWGRASS,'Snowy Grass',T.SNOW_TOP,T.SNOW_SIDE,T.DIRT,{hard:.6}); def(B.SNOW,'Snow Block',T.SNOW_TOP,T.SNOW_TOP,T.SNOW_TOP,{hard:.4}); def(B.WATER,'Water',T.WATER,T.WATER,T.WATER,{solid:false,transparent:true,cullSame:true,liquid:true,hard:0}); def(B.COAL,'Coal Ore',T.COAL,T.COAL,T.COAL,{hard:2}); def(B.IRON,'Iron Ore',T.IRON,T.IRON,T.IRON,{hard:2.2}); def(B.GOLD,'Gold Ore',T.GOLD,T.GOLD,T.GOLD,{hard:2.2}); def(B.DIAMOND,'Diamond Ore',T.DIAMOND,T.DIAMOND,T.DIAMOND,{hard:2.5}); def(B.BEDROCK,'Bedrock',T.BEDROCK,T.BEDROCK,T.BEDROCK,{hard:-1}); def(B.CACTUS,'Cactus',T.CACTUS_TOP,T.CACTUS_SIDE,T.CACTUS_TOP,{hard:.4}); def(B.FLOWER_R,'Rose',T.FLOWER_R,T.FLOWER_R,T.FLOWER_R,{solid:false,transparent:true,cross:true,hard:.05}); def(B.FLOWER_Y,'Dandelion',T.FLOWER_Y,T.FLOWER_Y,T.FLOWER_Y,{solid:false,transparent:true,cross:true,hard:.05}); def(B.TALLGRASS,'Tall Grass',T.TALLGRASS,T.TALLGRASS,T.TALLGRASS,{solid:false,transparent:true,cross:true,hard:.05}); D[B.GRASS].icon=T.GRASS_SIDE; D[B.LOG].icon=T.LOG_SIDE; /* ---------------- Texture atlas (procedural pixel art) ---------------- */ const atlas=document.createElement('canvas');atlas.width=atlas.height=256; const A=atlas.getContext('2d'); function tCtx(t){return{ox:(t%16)*16,oy:((t/16)|0)*16};} function px(t,x,y,c){const o=tCtx(t);A.fillStyle=c;A.fillRect(o.ox+x,o.oy+y,1,1);} function hsl(h,s,l,a){return a===undefined?`hsl(${h},${s}%,${l}%)`:`hsla(${h},${s}%,${l}%,${a})`;} function noiseFill(t,h,s,l,v,rng){for(let y=0;y<16;y++)for(let x=0;x<16;x++)px(t,x,y,hsl(h,s,l+(rng()-.5)*v));} function genTextures(){ const R=t=>mulberry32(0xC0FFEE+t*7919); let r; r=R(1);noiseFill(T.GRASS_TOP,100,42,42,16,r); for(let i=0;i<26;i++)px(T.GRASS_TOP,(r()*16)|0,(r()*16)|0,hsl(100,48,30+r()*8)); r=R(2);noiseFill(T.DIRT,28,38,36,14,r); for(let i=0;i<14;i++)px(T.DIRT,(r()*16)|0,(r()*16)|0,hsl(28,30,24)); r=R(3);noiseFill(T.GRASS_SIDE,28,38,36,14,r); for(let y=0;y<3;y++)for(let x=0;x<16;x++)px(T.GRASS_SIDE,x,y,hsl(100,45,40+(r()-.5)*14)); for(let x=0;x<16;x++)if(r()<.6)px(T.GRASS_SIDE,x,3,hsl(100,45,38+(r()-.5)*10)); r=R(4);noiseFill(T.STONE,220,3,47,11,r); for(let i=0;i<7;i++){let x=(r()*13)|0,y=(r()*15)|0,len=2+(r()*4)|0;for(let k=0;k<len;k++)px(T.STONE,x+k,y,hsl(220,3,35));} r=R(5);noiseFill(T.SAND,50,42,73,8,r); for(let i=0;i<10;i++)px(T.SAND,(r()*16)|0,(r()*16)|0,hsl(48,40,62)); r=R(6);for(let y=0;y<16;y++)for(let x=0;x<16;x++)px(T.WATER,x,y,hsl(218,72,40+(r()-.5)*10+((y%4===0&&r()<.5)?9:0),.85)); r=R(7);for(let x=0;x<16;x++){const base=(x%4<2)?31:23;for(let y=0;y<16;y++)px(T.LOG_SIDE,x,y,hsl(30,40,base+(r()-.5)*7));} r=R(8);for(let y=0;y<16;y++)for(let x=0;x<16;x++){const d=Math.max(Math.abs(x-7.5),Math.abs(y-7.5));px(T.LOG_TOP,x,y,hsl(33,42,(d|0)%2?40:28+(r()-.5)*6));} r=R(9);for(let y=0;y<16;y++)for(let x=0;x<16;x++){if(r()<.16)continue;px(T.LEAVES,x,y,hsl(108,48,22+r()*18));} r=R(10);for(let y=0;y<16;y++)for(let x=0;x<16;x++){let l=46+(r()-.5)*8;if(y%4===3)l=30;if((y<4&&x===7)||(y>=4&&y<8&&x===3)||(y>=8&&y<12&&x===11)||(y>=12&&x===5))l=30;px(T.PLANKS,x,y,hsl(33,42,l));} r=R(11);noiseFill(T.COBBLE,220,4,46,20,r); for(let i=0;i<14;i++){let x=(r()*16)|0,y=(r()*16)|0;for(let k=0;k<6;k++){px(T.COBBLE,x&15,y&15,hsl(220,4,24));x+=(r()*3-1)|0;y+=(r()*3-1)|0;if(x<0||y<0||x>15||y>15)break;}} r=R(12);A.fillStyle='rgba(180,220,255,0.10)';const g=tCtx(T.GLASS);A.fillRect(g.ox,g.oy,16,16); for(let i=0;i<16;i++){px(T.GLASS,i,0,hsl(0,0,88,.95));px(T.GLASS,i,15,hsl(0,0,88,.95));px(T.GLASS,0,i,hsl(0,0,88,.95));px(T.GLASS,15,i,hsl(0,0,88,.95));} for(let i=0;i<5;i++){px(T.GLASS,3+i,8-i,hsl(0,0,95,.9));px(T.GLASS,8+i,13-i,hsl(0,0,95,.9));} function ore(t,color){const rr=R(t+40);noiseFill(t,220,3,47,11,rr);for(let i=0;i<5;i++){const x=1+(rr()*13)|0,y=1+(rr()*13)|0;px(t,x,y,color);px(t,x+1,y,color);px(t,x,y+1,color);if(rr()<.6)px(t,x+1,y+1,color);}} ore(T.COAL,'#1c1c1c');ore(T.IRON,hsl(20,45,65));ore(T.GOLD,hsl(48,90,55));ore(T.DIAMOND,hsl(180,80,62)); r=R(13);for(let y=0;y<16;y++)for(let x=0;x<16;x++)px(T.BEDROCK,x,y,hsl(0,0,r()<.5?14+r()*10:34+r()*14)); r=R(14);noiseFill(T.SNOW_TOP,210,12,92,6,r); r=R(15);noiseFill(T.SNOW_SIDE,28,38,36,14,r); for(let y=0;y<4;y++)for(let x=0;x<16;x++)if(y<3||r()<.5)px(T.SNOW_SIDE,x,y,hsl(210,12,90+(r()-.5)*6)); r=R(16);for(let y=0;y<16;y++)for(let x=0;x<16;x++){const c=r();px(T.GRAVEL,x,y,c<.3?hsl(28,12,38):c<.6?hsl(220,4,52):hsl(220,4,40));} r=R(17);for(let y=0;y<16;y++)for(let x=0;x<16;x++){const row=(y/4)|0,off=row%2?4:0,mortar=(y%4===3)||(((x+off)%8)===7);px(T.BRICK,x,y,mortar?hsl(20,8,70):hsl(5,55,38+(r()-.5)*9));} function flower(t,petal,center){const rr=R(t+60);for(let y=8;y<16;y++)px(t,7+(y%3===0?1:0)-(y%5===0?1:0)? 7:7,y,hsl(110,50,30)); for(let y=9;y<16;y++)px(t,7,y,hsl(110,55,28+rr()*8));px(t,6,11,hsl(110,55,30));px(t,8,13,hsl(110,55,30)); const cx=7,cy=5;[[0,0],[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,-1],[1,-1],[-1,1]].forEach(o=>px(t,cx+o[0],cy+o[1],petal));px(t,cx,cy,center);} flower(T.FLOWER_R,hsl(0,75,48),hsl(0,80,32));flower(T.FLOWER_Y,hsl(52,95,55),hsl(40,95,45)); r=R(18);for(let i=0;i<7;i++){let x=2+i*2,h=5+(r()*6)|0;for(let y=15;y>15-h;y--){px(T.TALLGRASS,x+((y%4===0)?(r()<.5?1:-1):0),y,hsl(105,48,28+r()*16));}} r=R(19);for(let y=0;y<16;y++)for(let x=0;x<16;x++){let l=34+(r()-.5)*8;if(x%4===0)l=24;if(x%4===2&&y%4===1)l=55;px(T.CACTUS_SIDE,x,y,hsl(95,52,l));} r=R(20);for(let y=0;y<16;y++)for(let x=0;x<16;x++){const b=x===0||y===0||x===15||y===15;px(T.CACTUS_TOP,x,y,hsl(95,52,b?26:42+(r()-.5)*8));} r=R(21);for(let y=0;y<16;y++)for(let x=0;x<16;x++){let l=70+(r()-.5)*7;if(y===0||y===15)l=58;if(y>3&&y<12&&r()<.08)l=60;px(T.SANDSTONE,x,y,hsl(48,38,l));} r=R(22);noiseFill(T.GLOW,45,85,55,25,r);for(let i=0;i<9;i++){const x=(r()*14)|0,y=(r()*14)|0;px(T.GLOW,x,y,hsl(48,95,78));px(T.GLOW,x+1,y,hsl(48,95,72));px(T.GLOW,x,y+1,hsl(48,95,72));} for(let s=0;s<8;s++){const t=T.CRACK0+s,rr=mulberry32(777);A.fillStyle='rgba(0,0,0,0.8)'; const cracks=2+s;for(let c=0;c<cracks;c++){let x=4+(rr()*8)|0,y=4+(rr()*8)|0;const steps=3+s*2; for(let k=0;k<steps;k++){const o=tCtx(t);A.fillRect(o.ox+(x&15),o.oy+(y&15),1,1);x+=(rr()*3-1)|0;y+=(rr()*3-1)|0;}}} } /* ---------------- World ---------------- */ const CH=16,H=64,SEA=28; let RD=5; const chunks=new Map(),dirty=new Set(),editsByChunk=new Map(); const ckey=(cx,cz)=>cx+','+cz; const bidx=(x,y,z)=>(x*16+z)*H+y; let worldTime=0;const DAY=480; function biomeAt(x,z){const t=fbm2(x*.0035+900,z*.0035-700,3);if(t>.34)return'desert';if(t<-.42)return'snow';return fbm2(x*.012+33,z*.012-71,3)>.06?'forest':'plains';} function groundH(x,z){ const cont=fbm2(x*.0032,z*.0032,4),hills=fbm2(x*.014+50,z*.014+50,3); let m=fbm2(x*.007+200,z*.007+200,4);m=Math.max(0,m); let h=30+cont*12+hills*5+m*m*38; return clamp(h|0,4,H-10); } function genChunk(cx,cz){ const blocks=new Uint8Array(16*H*16); const hs=new Int16Array(256),bs=[]; for(let lx=0;lx<16;lx++)for(let lz=0;lz<16;lz++){ const wx=cx*16+lx,wz=cz*16+lz,h=groundH(wx,wz),bio=biomeAt(wx,wz); hs[lx*16+lz]=h;bs[lx*16+lz]=bio; for(let y=0;y<H;y++){ let id=B.AIR; if(y===0)id=B.BEDROCK; else if(y<h-3){ id=B.STONE;const r=h3(wx,y,wz); if(r<.0016&&y<14)id=B.DIAMOND;else if(r<.004&&y<22)id=B.GOLD;else if(r<.011&&y<34)id=B.IRON;else if(r<.022&&y<44)id=B.COAL;else if(r>.992)id=B.GRAVEL; }else if(y<h)id=(bio==='desert'||h<=SEA+1)?B.SAND:B.DIRT; else if(y===h){ if(h<=SEA+1)id=B.SAND; else if(bio==='desert')id=B.SAND; else if(bio==='snow')id=B.SNOWGRASS; else id=B.GRASS; }else if(y<=SEA)id=B.WATER; // caves if(id!==B.AIR&&id!==B.BEDROCK&&id!==B.WATER&&y>1){ const canBreach=h>SEA?y<=h:y<h-3; if(canBreach&&perlin.n3(wx*.065,y*.105,wz*.065)>.44)id=B.AIR; } blocks[bidx(lx,y,lz)]=id; } } // decorations const rng=mulberry32((cx*341873128+cz*132897987^SEED)>>>0); function top(lx,lz){for(let y=H-1;y>0;y--){const b=blocks[bidx(lx,y,lz)];if(b!==B.AIR)return y;}return 0;} for(let lx=0;lx<16;lx++)for(let lz=0;lz<16;lz++){ const bio=bs[lx*16+lz],wy=top(lx,lz),tb=blocks[bidx(lx,wy,lz)]; if(wy<=SEA)continue; if((tb===B.GRASS||tb===B.SNOWGRASS)&&lx>=2&&lx<=13&&lz>=2&&lz<=13){ const tc=bio==='forest'?.045:bio==='plains'?.006:bio==='snow'?.015:0; if(rng()<tc){ const th=4+((rng()*3)|0); for(let dy=th-2;dy<=th+1;dy++){const ly=wy+dy;if(ly>=H)break;const rad=dy>th-1?1:2; for(let dx=-rad;dx<=rad;dx++)for(let dz=-rad;dz<=rad;dz++){ if(Math.abs(dx)===rad&&Math.abs(dz)===rad&&rng()<.5)continue; const i=bidx(lx+dx,ly,lz+dz);if(blocks[i]===B.AIR)blocks[i]=B.LEAVES;}} for(let dy=1;dy<=th&&wy+dy<H;dy++)blocks[bidx(lx,wy+dy,lz)]=B.LOG; continue; } } if(tb===B.GRASS&&wy+1<H){ const r=rng(); if(r<.05)blocks[bidx(lx,wy+1,lz)]=B.TALLGRASS; else if(r<.062)blocks[bidx(lx,wy+1,lz)]=rng()<.5?B.FLOWER_R:B.FLOWER_Y; } if(bio==='desert'&&tb===B.SAND&&rng()<.004){ const ch2=2+((rng()*2)|0);for(let dy=1;dy<=ch2&&wy+dy<H;dy++)blocks[bidx(lx,wy+dy,lz)]=B.CACTUS; } } // apply saved edits const ed=editsByChunk.get(ckey(cx,cz)); if(ed)for(const k in ed){const[lx,y,lz]=k.split(',').map(Number);blocks[bidx(lx,y,lz)]=ed[k];} const chunk={cx,cz,blocks,meshO:null,meshW:null}; chunks.set(ckey(cx,cz),chunk); dirty.add(ckey(cx,cz)); [[1,0],[-1,0],[0,1],[0,-1]].forEach(o=>{if(chunks.has(ckey(cx+o[0],cz+o[1])))dirty.add(ckey(cx+o[0],cz+o[1]));}); return chunk; } function getBlock(x,y,z){ if(y<0)return B.BEDROCK;if(y>=H)return B.AIR; const c=chunks.get(ckey(Math.floor(x/16),Math.floor(z/16))); if(!c)return B.AIR; return c.blocks[bidx(((x%16)+16)%16,y,((z%16)+16)%16)]; } function setBlock(x,y,z,id,record=true){ if(y<0||y>=H)return; const cx=Math.floor(x/16),cz=Math.floor(z/16),c=chunks.get(ckey(cx,cz)); if(!c)return; const lx=((x%16)+16)%16,lz=((z%16)+16)%16; c.blocks[bidx(lx,y,lz)]=id; if(record){let ed=editsByChunk.get(ckey(cx,cz));if(!ed){ed={};editsByChunk.set(ckey(cx,cz),ed);}ed[lx+','+y+','+lz]=id;} dirty.add(ckey(cx,cz)); if(lx===0)dirty.add(ckey(cx-1,cz));if(lx===15)dirty.add(ckey(cx+1,cz)); if(lz===0)dirty.add(ckey(cx,cz-1));if(lz===15)dirty.add(ckey(cx,cz+1)); } /* ---------------- Meshing ---------------- */ const FACES=[ {dir:[-1,0,0],corners:[{pos:[0,1,0],uv:[0,1]},{pos:[0,0,0],uv:[0,0]},{pos:[0,1,1],uv:[1,1]},{pos:[0,0,1],uv:[1,0]}],shade:.6}, {dir:[1,0,0], corners:[{pos:[1,1,1],uv:[0,1]},{pos:[1,0,1],uv:[0,0]},{pos:[1,1,0],uv:[1,1]},{pos:[1,0,0],uv:[1,0]}],shade:.6}, {dir:[0,-1,0],corners:[{pos:[1,0,1],uv:[1,0]},{pos:[0,0,1],uv:[0,0]},{pos:[1,0,0],uv:[1,1]},{pos:[0,0,0],uv:[0,1]}],shade:.5}, {dir:[0,1,0], corners:[{pos:[0,1,1],uv:[1,1]},{pos:[1,1,1],uv:[0,1]},{pos:[0,1,0],uv:[1,0]},{pos:[1,1,0],uv:[0,0]}],shade:1}, {dir:[0,0,-1],corners:[{pos:[1,0,0],uv:[0,0]},{pos:[0,0,0],uv:[1,0]},{pos:[1,1,0],uv:[0,1]},{pos:[0,1,0],uv:[1,1]}],shade:.8}, {dir:[0,0,1], corners:[{pos:[0,0,1],uv:[0,0]},{pos:[1,0,1],uv:[1,0]},{pos:[0,1,1],uv:[0,1]},{pos:[1,1,1],uv:[1,1]}],shade:.8}]; const TS=1/16,PAD=.6/256; const AOF=[.45,.62,.8,1]; let matOpaque,matWater,atlasTex; function tileUV(t,ux,uy){const col=t%16,row=(t/16)|0;return[col*TS+PAD+ux*(TS-2*PAD),1-(row+1)*TS+PAD+uy*(TS-2*PAD)];} function meshChunk(c){ if(c.meshO){scene.remove(c.meshO);c.meshO.geometry.dispose();c.meshO=null;} if(c.meshW){scene.remove(c.meshW);c.meshW.geometry.dispose();c.meshW=null;} const pO=[],uO=[],cO=[],iO=[],pW=[],uW=[],cW=[],iW=[]; const ox=c.cx*16,oz=c.cz*16; function gb(x,y,z){ if(y<0)return B.BEDROCK;if(y>=H)return B.AIR; const cc=chunks.get(ckey(Math.floor(x/16),Math.floor(z/16))); if(!cc)return B.STONE; return cc.blocks[bidx(((x%16)+16)%16,y,((z%16)+16)%16)]; } const occ=(x,y,z)=>{const b=gb(x,y,z),d=D[b];return b!==B.AIR&&d.solid&&!d.transparent;}; for(let lx=0;lx<16;lx++)for(let lz=0;lz<16;lz++)for(let y=0;y<H;y++){ const id=c.blocks[bidx(lx,y,lz)];if(id===B.AIR)continue; const dd=D[id],wx=ox+lx,wz=oz+lz; if(dd.cross){ // X-shaped plant const t=dd.side,quads=[[[.15,0,.15],[.85,0,.85],[.15,1,.15],[.85,1,.85]],[[.85,0,.15],[.15,0,.85],[.85,1,.15],[.15,1,.85]]]; for(const q of quads)for(const flip of[0,1]){ const n=pO.length/3; const ord=flip?[1,0,3,2]:[0,1,2,3]; const uvs=[[0,0],[1,0],[0,1],[1,1]]; for(let k=0;k<4;k++){const v=q[ord[k]];pO.push(lx+v[0],y+v[1],lz+v[2]);const u=tileUV(t,uvs[k][0],uvs[k][1]);uO.push(u[0],u[1]);cO.push(.95,.95,.95);} iO.push(n,n+1,n+2,n+2,n+1,n+3); } continue; } const isW=id===B.WATER; const topY=(isW&&gb(wx,y+1,wz)!==B.WATER)?.875:1; for(let f=0;f<6;f++){ const F=FACES[f],nx=wx+F.dir[0],ny=y+F.dir[1],nz=wz+F.dir[2]; const nid=gb(nx,ny,nz),nd=D[nid]; const visible=nid===B.AIR||nd.cross||(nd.transparent&&(nid!==id||!dd.cullSame)); if(!visible)continue; const tile=F.dir[1]===1?dd.top:F.dir[1]===-1?dd.bottom:dd.side; const[P,U,C,I]=isW?[pW,uW,cW,iW]:[pO,uO,cO,iO]; const n=P.length/3,ao=[1,1,1,1]; const a=F.dir[0]?0:F.dir[1]?1:2,p1=(a+1)%3,p2=(a+2)%3; for(let k=0;k<4;k++){ const cr=F.corners[k]; let yy=cr.pos[1]===1?topY:cr.pos[1]; P.push(lx+cr.pos[0],y+yy,lz+cr.pos[2]); const u=tileUV(tile,cr.uv[0],cr.uv[1]);U.push(u[0],u[1]); let aoV=1; if(!isW){ const bp=[nx,ny,nz],s=[0,0,0],t2=[0,0,0]; s[p1]=cr.pos[p1]===1?1:-1;t2[p2]=cr.pos[p2]===1?1:-1; const s1=occ(bp[0]+s[0],bp[1]+s[1],bp[2]+s[2])?1:0; const s2=occ(bp[0]+t2[0],bp[1]+t2[1],bp[2]+t2[2])?1:0; const co=occ(bp[0]+s[0]+t2[0],bp[1]+s[1]+t2[1],bp[2]+s[2]+t2[2])?1:0; aoV=AOF[(s1&&s2)?0:3-s1-s2-co]; } ao[k]=aoV;const sh=F.shade*aoV;C.push(sh,sh,sh); } if(ao[0]+ao[3]<ao[1]+ao[2])I.push(n,n+1,n+3,n,n+3,n+2); else I.push(n,n+1,n+2,n+2,n+1,n+3); } } function build(pos,uv,col,idx,mat,ro){ if(!idx.length)return null; const g=new THREE.BufferGeometry(); g.setAttribute('position',new THREE.Float32BufferAttribute(pos,3)); g.setAttribute('uv',new THREE.Float32BufferAttribute(uv,2)); g.setAttribute('color',new THREE.Float32BufferAttribute(col,3)); g.setIndex(idx); const m=new THREE.Mesh(g,mat); m.position.set(ox,0,oz);m.matrixAutoUpdate=false;m.updateMatrix();m.renderOrder=ro; scene.add(m);return m; } c.meshO=build(pO,uO,cO,iO,matOpaque,0); c.meshW=build(pW,uW,cW,iW,matWater,2); } /* ---------------- Three.js setup ---------------- */ let scene,camera,renderer,sunLight,ambLight,skyPivot,sunMesh,moonMesh,stars,cloudGroup; let highlight,crackMesh,handGroup,handMesh; function setupScene(){ scene=new THREE.Scene(); scene.background=new THREE.Color(0x87b1ff); scene.fog=new THREE.Fog(0x87b1ff,RD*16*.55,RD*16*.95); camera=new THREE.PerspectiveCamera(75,innerWidth/innerHeight,.1,1000); camera.rotation.order='YXZ';scene.add(camera); renderer=new THREE.WebGLRenderer({antialias:false}); renderer.setPixelRatio(Math.min(devicePixelRatio,1.5)); renderer.setSize(innerWidth,innerHeight); renderer.domElement.id='gameCanvas'; document.body.appendChild(renderer.domElement); atlasTex=new THREE.CanvasTexture(atlas); atlasTex.magFilter=atlasTex.minFilter=THREE.NearestFilter;atlasTex.generateMipmaps=false; matOpaque=new THREE.MeshBasicMaterial({map:atlasTex,vertexColors:true,alphaTest:.5,side:THREE.FrontSide}); matWater=new THREE.MeshBasicMaterial({map:atlasTex,vertexColors:true,transparent:true,opacity:.78,depthWrite:false,side:THREE.DoubleSide}); ambLight=new THREE.AmbientLight(0xffffff,.7);scene.add(ambLight); sunLight=new THREE.DirectionalLight(0xffffff,.7);scene.add(sunLight);scene.add(sunLight.target); // sky skyPivot=new THREE.Group();scene.add(skyPivot); const sg=new THREE.PlaneGeometry(46,46); sunMesh=new THREE.Mesh(sg,new THREE.MeshBasicMaterial({color:0xffe14d,fog:false})); sunMesh.position.set(420,0,0);skyPivot.add(sunMesh); moonMesh=new THREE.Mesh(new THREE.PlaneGeometry(30,30),new THREE.MeshBasicMaterial({color:0xd8dce8,fog:false})); moonMesh.position.set(-420,0,0);skyPivot.add(moonMesh); const starPos=[];const srng=mulberry32(42); for(let i=0;i<450;i++){const v=new THREE.Vector3(srng()*2-1,srng()*2-1,srng()*2-1).normalize().multiplyScalar(400);starPos.push(v.x,v.y,v.z);} const stg=new THREE.BufferGeometry();stg.setAttribute('position',new THREE.Float32BufferAttribute(starPos,3)); stars=new THREE.Points(stg,new THREE.PointsMaterial({color:0xffffff,size:1.6,fog:false,transparent:true,opacity:0})); skyPivot.add(stars); // clouds cloudGroup=new THREE.Group();scene.add(cloudGroup); const crng=mulberry32(7); for(let i=0;i<26;i++){ const m=new THREE.Mesh(new THREE.BoxGeometry(1,1,1),new THREE.MeshBasicMaterial({color:0xffffff,transparent:true,opacity:.5})); m.scale.set(12+crng()*26,3.2,10+crng()*20); m.position.set((crng()-.5)*420,70+crng()*8,(crng()-.5)*420); cloudGroup.add(m); } // block highlight + crack overlay highlight=new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(1.002,1.002,1.002)),new THREE.LineBasicMaterial({color:0x000000,transparent:true,opacity:.7})); highlight.visible=false;scene.add(highlight); crackMesh=new THREE.Mesh(new THREE.BoxGeometry(1.004,1.004,1.004),new THREE.MeshBasicMaterial({map:atlasTex,transparent:true,depthWrite:false,polygonOffset:true,polygonOffsetFactor:-2})); crackMesh.visible=false;crackMesh.renderOrder=1;scene.add(crackMesh); // held block handGroup=new THREE.Group();camera.add(handGroup); handMesh=new THREE.Mesh(new THREE.BoxGeometry(.35,.35,.35),matOpaque); handMesh.position.set(.42,-.42,-.65);handMesh.rotation.set(.2,Math.PI/5,0); handGroup.add(handMesh); addEventListener('resize',()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight);}); } function setCubeUV(geom,tiles){ // tiles: [px,nx,py,ny,pz,nz] const uv=geom.attributes.uv; for(let face=0;face<6;face++){const t=tiles[face]; for(let v=0;v<4;v++){const i=face*4+v; const u0=tileUV(t,uv.getX(i)>.5?1:0,uv.getY(i)>.5?1:0); uv.setXY(i,u0[0],u0[1]);}} uv.needsUpdate=true; } function updateHandMesh(){ const id=hotbar[hotSel],dd=D[id]; setCubeUV(handMesh.geometry,[dd.side,dd.side,dd.top,dd.bottom,dd.side,dd.side]); } function setCrackStage(s){setCubeUV(crackMesh.geometry,Array(6).fill(T.CRACK0+clamp(s,0,7)));} /* ---------------- Audio ---------------- */ let AC=null; function ac(){if(!AC)AC=new(window.AudioContext||window.webkitAudioContext)();if(AC.state==='suspended')AC.resume();return AC;} function sfx(f1,f2,dur,type,vol){ try{const a=ac(),o=a.createOscillator(),g=a.createGain(); o.type=type||'square';o.frequency.setValueAtTime(f1,a.currentTime); o.frequency.exponentialRampToValueAtTime(Math.max(20,f2||f1),a.currentTime+dur); g.gain.setValueAtTime(vol||.15,a.currentTime); g.gain.exponentialRampToValueAtTime(.001,a.currentTime+dur); o.connect(g).connect(a.destination);o.start();o.stop(a.currentTime+dur);}catch(e){} } const sndDig=id=>{const d=D[id];if(id===B.STONE||id===B.COBBLE||d.hard>1.2)sfx(95,55,.12,'square',.18);else if(id===B.SAND||id===B.GRAVEL)sfx(180,90,.1,'triangle',.2);else sfx(150,80,.1,'triangle',.18);}; const sndPlace=()=>sfx(220,140,.08,'square',.14); const sndHurt=()=>sfx(280,90,.25,'sawtooth',.2); const sndPop=()=>sfx(400,900,.12,'square',.15); /* ---------------- Particles ---------------- */ const particles=[];const tileColorCache={}; function tileColor(t){ if(tileColorCache[t])return tileColorCache[t]; const o=tCtx(t),d=A.getImageData(o.ox,o.oy,16,16).data; let r=0,g=0,b=0,n=0; for(let i=0;i<d.length;i+=4)if(d[i+3]>100){r+=d[i];g+=d[i+1];b+=d[i+2];n++;} const c=n?new THREE.Color(r/n/255,g/n/255,b/n/255):new THREE.Color(.5,.5,.5); return tileColorCache[t]=c; } const partGeo=new THREE.BoxGeometry(.1,.1,.1);const partMats={}; function spawnParticles(x,y,z,tile,count){ const c=tileColor(tile),key=c.getHexString(); if(!partMats[key])partMats[key]=new THREE.MeshBasicMaterial({color:c}); for(let i=0;i<count;i++){ const m=new THREE.Mesh(partGeo,partMats[key]); m.position.set(x+Math.random(),y+Math.random(),z+Math.random()); scene.add(m); particles.push({m,vx:(Math.random()-.5)*4,vy:2+Math.random()*3,vz:(Math.random()-.5)*4,life:.5+Math.random()*.3}); } } function updateParticles(dt){ for(let i=particles.length-1;i>=0;i--){const p=particles[i]; p.life-=dt;p.vy-=18*dt; p.m.position.x+=p.vx*dt;p.m.position.y+=p.vy*dt;p.m.position.z+=p.vz*dt; if(p.life<=0){scene.remove(p.m);particles.splice(i,1);}} } /* ---------------- Mobs ---------------- */ const mobs=[]; function lambBox(w,h,d,color,x,y,z,parent,pivotTop){ const g=new THREE.BoxGeometry(w,h,d); if(pivotTop)g.translate(0,-h/2,0); const m=new THREE.Mesh(g,new THREE.MeshLambertMaterial({color})); m.position.set(x,y,z);parent.add(m);return m; } class Mob{ constructor(type,x,y,z){ this.type=type;this.x=x;this.y=y;this.z=z;this.vy=0;this.dir=Math.random()*Math.PI*2; this.state='idle';this.timer=1+Math.random()*3;this.animT=0;this.onGround=false; this.attackCd=0;this.legs=[];this.g=new THREE.Group(); if(type==='pig'){this.hp=10;this.speed=1.2; const c=0xeb9c9c;lambBox(.62,.5,.95,c,0,.62,0,this.g); const head=lambBox(.48,.48,.42,0xf0a8a8,0,.72,.62,this.g); lambBox(.24,.16,.06,0xd87f7f,0,-.04,.24,head); lambBox(.07,.07,.02,0x202020,-.13,.1,.22,head);lambBox(.07,.07,.02,0x202020,.13,.1,.22,head); [[-.2,-.32],[.2,-.32],[-.2,.32],[.2,.32]].forEach(o=>this.legs.push(lambBox(.18,.38,.18,0xdb8e8e,o[0],.38,o[1],this.g,true))); }else if(type==='sheep'){this.hp=8;this.speed=1; lambBox(.7,.62,1.05,0xe8e8e8,0,.78,0,this.g); const head=lambBox(.4,.4,.35,0xd8c5b8,0,.95,.65,this.g); lambBox(.06,.06,.02,0x202020,-.1,.05,.18,head);lambBox(.06,.06,.02,0x202020,.1,.05,.18,head); [[-.22,-.35],[.22,-.35],[-.22,.35],[.22,.35]].forEach(o=>this.legs.push(lambBox(.17,.48,.17,0xcfcfcf,o[0],.48,o[1],this.g,true))); }else{ // zombie this.hp=20;this.speed=1.7; const skin=0x57a04b; [[-.13,0],[.13,0]].forEach(o=>this.legs.push(lambBox(.22,.72,.22,0x2e6b8a,o[0],.72,o[1],this.g,true))); lambBox(.52,.68,.3,0x3a7ca5,0,1.06,0,this.g); const head=lambBox(.48,.48,.48,skin,0,1.64,0,this.g); lambBox(.08,.08,.02,0x111111,-.11,.05,.25,head);lambBox(.08,.08,.02,0x111111,.11,.05,.25,head); this.arms=[lambBox(.18,.18,.62,skin,-.35,1.28,.28,this.g),lambBox(.18,.18,.62,skin,.35,1.28,.28,this.g)]; } this.g.position.set(x,y,z);scene.add(this.g); } solidAt(x,y,z){const b=getBlock(Math.floor(x),Math.floor(y),Math.floor(z));return D[b].solid;} damage(n,kx,kz){ this.hp-=n;this.vy=5;this.x+=kx*.3;this.z+=kz*.3; this.g.traverse(o=>{if(o.material)o.material.emissive=new THREE.Color(0x880000);}); setTimeout(()=>this.g.traverse(o=>{if(o.material)o.material.emissive=new THREE.Color(0)}),140); sfx(200,80,.15,'sawtooth',.15); if(this.hp<=0){ spawnParticles(this.x-.5,this.y+.4,this.z-.5,this.type==='zombie'?T.LEAVES:T.FLOWER_R,14); sndPop();this.dead=true; } } update(dt){ this.timer-=dt;this.attackCd-=dt; const dx=player.x-this.x,dz=player.z-this.z,distP=Math.hypot(dx,dz); if(this.type==='zombie'&&distP<14){this.state='walk';this.dir=Math.atan2(dx,dz); if(distP<1.3&&this.attackCd<=0&&Math.abs(player.y-this.y)<2){this.attackCd=1;hurt(3,dx/distP,dz/distP);} }else if(this.timer<=0){ this.timer=1.5+Math.random()*4; this.state=Math.random()<.55?'walk':'idle'; if(this.state==='walk')this.dir=Math.random()*Math.PI*2; } let spd=this.state==='walk'?this.speed:0; if(this.type==='zombie'&&distP<14)spd=2.4; if(spd>0){ const fx=Math.sin(this.dir),fz=Math.cos(this.dir); const nx=this.x+fx*spd*dt,nz=this.z+fz*spd*dt; const lx=nx+fx*.35,lz=nz+fz*.35; const blocked=this.solidAt(lx,this.y+.1,lz)||this.solidAt(lx,this.y+1.1,lz); if(blocked){ if(this.onGround&&!this.solidAt(lx,this.y+1.4,lz)&&!this.solidAt(this.x,this.y+2.2,this.z))this.vy=7; else this.dir+=Math.PI+(Math.random()-.5); }else{this.x=nx;this.z=nz;} this.animT+=dt*spd*3.4; } this.vy-=22*dt; const inW=D[getBlock(Math.floor(this.x),Math.floor(this.y+.3),Math.floor(this.z))].liquid; if(inW){this.vy=Math.max(this.vy,-1);this.vy+=26*dt;this.vy=Math.min(this.vy,2.2);} this.y+=this.vy*dt;this.onGround=false; if(this.vy<=0&&this.solidAt(this.x,this.y,this.z)){this.y=Math.floor(this.y)+1;this.vy=0;this.onGround=true;} if(this.vy>0&&this.solidAt(this.x,this.y+1.8,this.z))this.vy=0; if(this.y<-10)this.dead=true; const sw=Math.sin(this.animT)*.7; this.legs.forEach((l,i)=>l.rotation.x=i%2?sw:-sw); this.g.position.set(this.x,this.y,this.z); this.g.rotation.y=this.dir; if(distP>70)this.dead=true; if(this.type==='zombie'&&dayFactor>.6&&Math.random()<dt*.4){spawnParticles(this.x-.5,this.y+1.5,this.z-.5,T.GLOW,3);this.dead=true;} } } function topSolidY(x,z){ if(!chunks.has(ckey(Math.floor(x/16),Math.floor(z/16))))return-1; for(let y=H-1;y>0;y--){const b=getBlock(x,y,z);if(b===B.WATER)return-1;if(D[b].solid)return y;} return-1; } let mobTimer=0; function updateMobs(dt){ mobTimer-=dt; if(mobTimer<=0){ mobTimer=1.5; const night=sunElev<-.05; const zCount=mobs.filter(m=>m.type==='zombie').length; const pCount=mobs.length-zCount; const ang=Math.random()*Math.PI*2,r=18+Math.random()*22; const x=Math.floor(player.x+Math.sin(ang)*r),z=Math.floor(player.z+Math.cos(ang)*r); const y=topSolidY(x,z); if(y>0&&y<H-3){ const tb=getBlock(x,y,z); if((tb===B.GRASS||tb===B.SAND||tb===B.SNOWGRASS)&&!D[getBlock(x,y+1,z)].solid){ if(night&&zCount<8&&Math.random()<.75)mobs.push(new Mob('zombie',x+.5,y+1,z+.5)); else if(pCount<10)mobs.push(new Mob(Math.random()<.5?'pig':'sheep',x+.5,y+1,z+.5)); } } } for(let i=mobs.length-1;i>=0;i--){ const m=mobs[i];m.update(dt); if(m.dead){scene.remove(m.g);mobs.splice(i,1);} } } /* ---------------- Player ---------------- */ const player={x:8,y:40,z:8,vx:0,vy:0,vz:0,yaw:0,pitch:0,onGround:false,fly:false,hp:20,peakY:0,bobT:0,sneak:false,sprint:false,inWater:false}; let spawnPoint={x:8,y:40,z:8}; const keys={};let dead=false,invOpen=false,playing=false; let lastHurtT=-99,regenT=0,lastWTap=0; const HALF=.3,PH=1.8,EYE=1.62; function collides(){ const x0=Math.floor(player.x-HALF),x1=Math.floor(player.x+HALF); const y0=Math.floor(player.y),y1=Math.floor(player.y+PH); const z0=Math.floor(player.z-HALF),z1=Math.floor(player.z+HALF); for(let x=x0;x<=x1;x++)for(let y=y0;y<=y1;y++)for(let z=z0;z<=z1;z++) if(D[getBlock(x,y,z)].solid)return true; return false; } function hurt(n,kx,kz){ if(dead||n<=0)return; player.hp-=n;lastHurtT=worldTime; if(kx!==undefined){player.vx+=kx*7;player.vz+=kz*7;player.vy=Math.max(player.vy,4.5);} sndHurt(); const f=$('damageFlash');f.style.transition='none';f.style.opacity=.5; requestAnimationFrame(()=>{f.style.transition='opacity .4s';f.style.opacity=0;}); updateHearts(); if(player.hp<=0){dead=true;document.exitPointerLock();$('deathScreen').style.display='flex';} } function updatePlayer(dt){ const steps=Math.max(1,Math.ceil(dt/.0333));const sdt=dt/steps; for(let s=0;s<steps;s++)stepPlayer(sdt); // camera const sneakOff=player.sneak&&player.onGround?-.15:0; let bobO=0; const hSpeed=Math.hypot(player.vx,player.vz); if(player.onGround&&hSpeed>.5){player.bobT+=dt*hSpeed*1.7;bobO=Math.sin(player.bobT*4)*.05;} camera.position.set(player.x,player.y+EYE+sneakOff+bobO,player.z); camera.rotation.set(player.pitch,player.yaw,0); const tFov=player.sprint&&hSpeed>4?84:75; camera.fov=lerp(camera.fov,tFov,dt*8);camera.updateProjectionMatrix(); // water check const eyeB=getBlock(Math.floor(player.x),Math.floor(player.y+EYE+sneakOff),Math.floor(player.z)); $('waterOverlay').style.display=D[eyeB].liquid?'block':'none'; if(D[eyeB].liquid){scene.fog.near=2;scene.fog.far=24;} // regen regenT+=dt; if(regenT>3){regenT=0;if(player.hp<20&&worldTime-lastHurtT>6){player.hp++;updateHearts();}} // hand swing if(swingT>0){swingT-=dt*5;handGroup.rotation.x=-Math.sin(Math.max(0,swingT)*Math.PI)*.7;handGroup.position.y=-Math.sin(Math.max(0,swingT)*Math.PI)*.15;} } function stepPlayer(dt){ const fwd=[-Math.sin(player.yaw),-Math.cos(player.yaw)],right=[Math.cos(player.yaw),-Math.sin(player.yaw)]; let mx=0,mz=0; if(keys.KeyW){mx+=fwd[0];mz+=fwd[1];} if(keys.KeyS){mx-=fwd[0];mz-=fwd[1];} if(keys.KeyD){mx+=right[0];mz+=right[1];} if(keys.KeyA){mx-=right[0];mz-=right[1];} const ml=Math.hypot(mx,mz);if(ml>0){mx/=ml;mz/=ml;} player.sneak=!!keys.ShiftLeft&&!player.fly; if(!keys.KeyW)player.sprint=false; const feetB=D[getBlock(Math.floor(player.x),Math.floor(player.y+.2),Math.floor(player.z))]; const bodyB=D[getBlock(Math.floor(player.x),Math.floor(player.y+1),Math.floor(player.z))]; player.inWater=feetB.liquid||bodyB.liquid; let speed=player.fly?11:player.inWater?2.6:player.sneak?1.4:player.sprint?5.6:4.3; const acc=player.fly?40:(player.onGround?55:12); player.vx+=clamp(mx*speed-player.vx,-acc*dt,acc*dt); player.vz+=clamp(mz*speed-player.vz,-acc*dt,acc*dt); if(player.fly){ let ty=0;if(keys.Space)ty=9;if(keys.ShiftLeft)ty=-9; player.vy+=clamp(ty-player.vy,-50*dt,50*dt); }else if(player.inWater){ player.vy-=8*dt;player.vy=Math.max(player.vy,-2.6); if(keys.Space)player.vy+=clamp(3.2-player.vy,0,30*dt); player.peakY=player.y; }else{ player.vy-=26*dt;player.vy=Math.max(player.vy,-50); if(keys.Space&&player.onGround){player.vy=8.2;player.onGround=false;} } // Y player.y+=player.vy*dt; const wasFalling=player.vy<0; if(collides()){ if(player.vy<0){player.y=Math.floor(player.y)+1+1e-4; if(wasFalling){player.onGround=true; const fall=player.peakY-player.y; if(fall>3.5&&!player.inWater&&!player.fly)hurt(Math.floor(fall-3)); player.peakY=player.y;} }else player.y=Math.floor(player.y+PH)-PH-1e-4; player.vy=0; }else{player.onGround=false;} if(player.onGround||player.fly)player.peakY=player.y; else player.peakY=Math.max(player.peakY,player.y); // X player.x+=player.vx*dt; if(collides()){ if(player.vx>0)player.x=Math.floor(player.x+HALF)-HALF-1e-4; else player.x=Math.floor(player.x-HALF)+1+HALF+1e-4; player.vx=0; } // Z player.z+=player.vz*dt; if(collides()){ if(player.vz>0)player.z=Math.floor(player.z+HALF)-HALF-1e-4; else player.z=Math.floor(player.z-HALF)+1+HALF+1e-4; player.vz=0; } if(player.y<-12){hurt(100);} } /* ---------------- Raycasting + block interaction ---------------- */ function raycast(maxD){ const o=camera.position,d=camera.getWorldDirection(new THREE.Vector3()); let x=Math.floor(o.x),y=Math.floor(o.y),z=Math.floor(o.z); const stX=d.x>0?1:-1,stY=d.y>0?1:-1,stZ=d.z>0?1:-1; const tdX=Math.abs(1/d.x),tdY=Math.abs(1/d.y),tdZ=Math.abs(1/d.z); let tmX=d.x!==0?((x+(stX>0?1:0))-o.x)/d.x:1e30; let tmY=d.y!==0?((y+(stY>0?1:0))-o.y)/d.y:1e30; let tmZ=d.z!==0?((z+(stZ>0?1:0))-o.z)/d.z:1e30; let nx=0,ny=0,nz=0,t=0; for(let i=0;i<120;i++){ const b=getBlock(x,y,z),dd=D[b]; if(b!==B.AIR&&(dd.solid||dd.cross))return{x,y,z,nx,ny,nz,id:b,t}; if(tmX<tmY&&tmX<tmZ){x+=stX;t=tmX;tmX+=tdX;nx=-stX;ny=0;nz=0;} else if(tmY<tmZ){y+=stY;t=tmY;tmY+=tdY;nx=0;ny=-stY;nz=0;} else{z+=stZ;t=tmZ;tmZ+=tdZ;nx=0;ny=0;nz=-stZ;} if(t>maxD)return null; } return null; } let mouseL=false,mouseR=false,breakTarget=null,breakProgress=0,placeTimer=0,swingT=0; function tryHitMob(){ const o=camera.position,d=camera.getWorldDirection(new THREE.Vector3()); let best=null,bestT=4.2; for(const m of mobs){ const c=new THREE.Vector3(m.x,m.y+.9,m.z).sub(o); const t=c.dot(d); if(t>0&&t<bestT){ const perp=c.clone().addScaledVector(d,-t).length(); if(perp<.85){best=m;bestT=t;} } } if(best){ const dx=best.x-player.x,dz=best.z-player.z,l=Math.hypot(dx,dz)||1; best.damage(5,dx/l,dz/l);return true; } return false; } function updateBreaking(dt){ const hit=raycast(5); if(hit){highlight.visible=true;highlight.position.set(hit.x+.5,hit.y+.5,hit.z+.5);} else highlight.visible=false; if(mouseL&&hit&&!invOpen){ const dd=D[hit.id]; if(dd.hard>=0){ const same=breakTarget&&breakTarget.x===hit.x&&breakTarget.y===hit.y&&breakTarget.z===hit.z; if(!same){breakTarget={x:hit.x,y:hit.y,z:hit.z};breakProgress=0;} breakProgress+=dt/Math.max(.05,dd.hard); swingT=1; if(breakProgress>=1){ setBlock(hit.x,hit.y,hit.z,B.AIR); spawnParticles(hit.x,hit.y,hit.z,dd.icon,12); sndDig(hit.id); breakTarget=null;breakProgress=0; } } }else{breakTarget=null;breakProgress=0;} if(breakTarget&&breakProgress>0){ crackMesh.visible=true; crackMesh.position.set(breakTarget.x+.5,breakTarget.y+.5,breakTarget.z+.5); setCrackStage(Math.floor(breakProgress*8)); }else crackMesh.visible=false; placeTimer-=dt; if(mouseR&&!invOpen&&placeTimer<=0&&hit){ placeTimer=.22; const px2=hit.x+hit.nx,py2=hit.y+hit.ny,pz2=hit.z+hit.nz; const cur=getBlock(px2,py2,pz2); if(cur===B.AIR||cur===B.WATER||cur===B.TALLGRASS){ const id=hotbar[hotSel],dd=D[id]; let blocked=false; if(dd.solid){ const ox=Math.abs(player.x-(px2+.5)),oz=Math.abs(player.z-(pz2+.5)); if(ox<HALF+.5&&oz<HALF+.5&&player.y+PH>py2&&player.y<py2+1)blocked=true; } if(!blocked){setBlock(px2,py2,pz2,id);sndPlace();swingT=1;} } } } /* ---------------- Day/Night ---------------- */ let sunElev=1,dayFactor=1; const colNight=new THREE.Color(.04,.05,.12),colDay=new THREE.Color(.49,.66,1),colSet=new THREE.Color(1,.55,.28); function updateDayNight(dt){ worldTime+=dt; const tod=(worldTime/DAY)%1; const ang=tod*Math.PI*2; sunElev=Math.sin(ang); dayFactor=smooth(-.08,.16,sunElev); skyPivot.position.copy(camera.position); skyPivot.rotation.z=ang; sunMesh.lookAt(camera.position);moonMesh.lookAt(camera.position); stars.material.opacity=1-dayFactor; const sky=colNight.clone().lerp(colDay,dayFactor); const sunsetF=clamp(1-Math.abs(sunElev)*5,0,1)*.55; sky.lerp(colSet,sunsetF); scene.background.copy(sky);scene.fog.color.copy(sky); scene.fog.near=RD*16*.55;scene.fog.far=RD*16*.95; const bright=.28+.72*dayFactor; matOpaque.color.setScalar(bright);matWater.color.setScalar(bright); ambLight.intensity=.35+.45*dayFactor; sunLight.intensity=.15+.65*dayFactor; const sd=new THREE.Vector3(Math.cos(ang),Math.abs(Math.sin(ang))*.8+.2,.3).normalize(); sunLight.position.copy(camera.position).addScaledVector(sd,80); sunLight.target.position.copy(camera.position); // clouds drift cloudGroup.children.forEach(c=>{ c.position.x+=1.4*dt; if(c.position.x-player.x>240)c.position.x-=480; if(c.position.x-player.x<-240)c.position.x+=480; if(c.position.z-player.z>240)c.position.z-=480; if(c.position.z-player.z<-240)c.position.z+=480; c.material.opacity=.18+.34*dayFactor; }); } /* ---------------- Chunk streaming ---------------- */ function updateStream(){ const pcx=Math.floor(player.x/16),pcz=Math.floor(player.z/16); const want=[]; for(let dx=-RD;dx<=RD;dx++)for(let dz=-RD;dz<=RD;dz++){ const cx=pcx+dx,cz=pcz+dz; if(!chunks.has(ckey(cx,cz)))want.push([cx,cz,dx*dx+dz*dz]); } want.sort((a,b)=>a[2]-b[2]); for(let i=0;i<Math.min(2,want.length);i++)genChunk(want[i][0],want[i][1]); if(dirty.size){ const list=[...dirty].map(k=>{const[cx,cz]=k.split(',').map(Number);return[k,(cx-pcx)**2+(cz-pcz)**2];}).sort((a,b)=>a[1]-b[1]); let n=0; for(const[k]of list){ const c=chunks.get(k);dirty.delete(k); if(c){meshChunk(c);if(++n>=3)break;} } } if(frame%180===0){ for(const[k,c]of chunks){ const[cx,cz]=k.split(',').map(Number); if(Math.abs(cx-pcx)>RD+2||Math.abs(cz-pcz)>RD+2){ if(c.meshO){scene.remove(c.meshO);c.meshO.geometry.dispose();} if(c.meshW){scene.remove(c.meshW);c.meshW.geometry.dispose();} chunks.delete(k); } } } } /* ---------------- UI ---------------- */ let hotbar=[B.GRASS,B.DIRT,B.STONE,B.LOG,B.PLANKS,B.COBBLE,B.GLASS,B.SAND,B.BRICK],hotSel=0; const INV_ITEMS=[B.GRASS,B.DIRT,B.STONE,B.COBBLE,B.PLANKS,B.LOG,B.LEAVES,B.SAND,B.SANDSTONE,B.GRAVEL,B.BRICK,B.GLASS,B.GLOW,B.SNOWGRASS,B.SNOW,B.COAL,B.IRON,B.GOLD,B.DIAMOND,B.CACTUS,B.FLOWER_R,B.FLOWER_Y,B.TALLGRASS,B.WATER,B.BEDROCK]; function drawIcon(cv,id){ const ctx=cv.getContext('2d');ctx.imageSmoothingEnabled=false; ctx.clearRect(0,0,cv.width,cv.height); const t=D[id].icon,o=tCtx(t); ctx.drawImage(atlas,o.ox,o.oy,16,16,0,0,cv.width,cv.height); } function buildHotbar(){ const hb=$('hotbar');hb.innerHTML=''; for(let i=0;i<9;i++){ const s=document.createElement('div');s.className='slot'+(i===hotSel?' sel':''); const cv=document.createElement('canvas');cv.width=cv.height=32;cv.className='pixel'; drawIcon(cv,hotbar[i]);s.appendChild(cv);hb.appendChild(s); } } function selectSlot(i){ hotSel=((i%9)+9)%9; document.querySelectorAll('#hotbar .slot').forEach((s,k)=>s.classList.toggle('sel',k===hotSel)); updateHandMesh();showItemName(); } let nameTimer; function showItemName(){ const el=$('itemname');el.textContent=D[hotbar[hotSel]].name;el.style.opacity=1; clearTimeout(nameTimer);nameTimer=setTimeout(()=>el.style.opacity=0,1300); } function updateHearts(){ const n=Math.ceil(clamp(player.hp,0,20)/2);let s=''; for(let i=0;i<10;i++)s+=`<span style="color:${i<n?'#e3340b':'#3a3a3a'}">♥</span>`; $('hearts').innerHTML=s; } function buildInventory(){ const g=$('invGrid');g.innerHTML=''; INV_ITEMS.forEach(id=>{ const s=document.createElement('div');s.className='invSlot';s.title=D[id].name; const cv=document.createElement('canvas');cv.width=cv.height=32;cv.className='pixel'; drawIcon(cv,id);s.appendChild(cv); s.onclick=()=>{hotbar[hotSel]=id;buildHotbar();updateHandMesh();showItemName();closeInv();}; g.appendChild(s); }); } function openInv(){invOpen=true;$('invScreen').style.display='block';document.exitPointerLock();} function closeInv(){invOpen=false;$('invScreen').style.display='none';if(playing&&!dead)renderer.domElement.requestPointerLock();} /* ---------------- Save / Load ---------------- */ const SAVE_KEY='minejs_save_v1'; function save(){ if(!playing)return; const ed={};for(const[k,v]of editsByChunk)ed[k]=v; try{localStorage.setItem(SAVE_KEY,JSON.stringify({seed:SEED,edits:ed,time:worldTime,hotbar, p:{x:player.x,y:player.y,z:player.z,yaw:player.yaw,pitch:player.pitch,hp:player.hp,fly:player.fly}}));}catch(e){} } function load(){ try{ const s=JSON.parse(localStorage.getItem(SAVE_KEY)); if(!s)return null; return s; }catch(e){return null;} } /* ---------------- Input ---------------- */ function setupInput(){ const canvas=renderer.domElement; document.addEventListener('keydown',e=>{ if(e.code==='F5'||e.code==='F12')return; keys[e.code]=true; if(!playing)return; if(e.code.startsWith('Digit')){const n=parseInt(e.code.slice(5));if(n>=1&&n<=9)selectSlot(n-1);} if(e.code==='KeyW'){ // double-tap sprint const now=performance.now(); if(now-lastWTap<280)player.sprint=true; lastWTap=now; } if(e.code==='KeyF'){player.fly=!player.fly;player.vy=0;showMsg(player.fly?'Flight: ON':'Flight: OFF');} if(e.code==='KeyE'){if(invOpen)closeInv();else if(!dead)openInv();} if(e.code==='KeyN'){worldTime+=DAY/2;showMsg('Time skipped');} if(e.code==='KeyG'||e.code==='KeyH'){ const hit=raycast(20); if(hit){const t=e.code==='KeyG'?(Math.random()<.5?'pig':'sheep'):'zombie'; mobs.push(new Mob(t,hit.x+.5,hit.y+1,hit.z+.5));sndPop();} } if(e.code==='BracketLeft'){RD=clamp(RD-1,3,8);showMsg('Render distance: '+RD);} if(e.code==='BracketRight'){RD=clamp(RD+1,3,8);showMsg('Render distance: '+RD);} if(e.code==='Space')e.preventDefault(); }); document.addEventListener('keyup',e=>{keys[e.code]=false;}); document.addEventListener('mousemove',e=>{ if(document.pointerLockElement!==canvas)return; player.yaw-=e.movementX*.0022; player.pitch=clamp(player.pitch-e.movementY*.0022,-Math.PI/2+.01,Math.PI/2-.01); }); canvas.addEventListener('mousedown',e=>{ ac(); if(document.pointerLockElement!==canvas)return; if(e.button===0){ if(tryHitMob()){swingT=1;} mouseL=true; } if(e.button===2)mouseR=true; if(e.button===1){ e.preventDefault(); const hit=raycast(5); if(hit&&hit.id!==B.AIR){hotbar[hotSel]=hit.id;buildHotbar();updateHandMesh();showItemName();} } }); document.addEventListener('mouseup',e=>{ if(e.button===0)mouseL=false; if(e.button===2)mouseR=false; }); document.addEventListener('wheel',e=>{ if(!playing||invOpen)return; selectSlot(hotSel+(e.deltaY>0?1:-1)); },{passive:true}); document.addEventListener('contextmenu',e=>e.preventDefault()); document.addEventListener('pointerlockchange',()=>{ if(document.pointerLockElement===canvas){ $('pauseScreen').style.display='none'; $('titleScreen').style.display='none'; $('hud').style.display='block'; playing=true; }else{ mouseL=mouseR=false;keys.KeyW=keys.KeyA=keys.KeyS=keys.KeyD=keys.Space=keys.ShiftLeft=false; if(playing&&!invOpen&&!dead)$('pauseScreen').style.display='flex'; } }); $('playBtn').onclick=()=>{ac();canvas.requestPointerLock();}; $('resumeBtn').onclick=()=>canvas.requestPointerLock(); $('newWorldBtn').onclick=()=>{localStorage.removeItem(SAVE_KEY);location.reload();}; $('resetBtn').onclick=()=>{localStorage.removeItem(SAVE_KEY);location.reload();}; $('respawnBtn').onclick=()=>{ dead=false;player.hp=20;updateHearts(); player.x=spawnPoint.x;player.y=spawnPoint.y;player.z=spawnPoint.z; player.vx=player.vy=player.vz=0;player.peakY=player.y; $('deathScreen').style.display='none'; canvas.requestPointerLock(); }; } let msgTimer; function showMsg(t){ const el=$('itemname');el.textContent=t;el.style.opacity=1; clearTimeout(msgTimer);msgTimer=setTimeout(()=>el.style.opacity=0,1400); } /* ---------------- Debug ---------------- */ let fps=0,fpsAcc=0,fpsN=0; function updateDebug(dt){ fpsAcc+=dt;fpsN++; if(fpsAcc>.5){fps=Math.round(fpsN/fpsAcc);fpsAcc=0;fpsN=0;} const tod=((worldTime/DAY)%1*24+6)%24; $('debug').innerHTML= `MineJS ${fps} fps<br>`+ `XYZ: ${player.x.toFixed(1)} / ${player.y.toFixed(1)} / ${player.z.toFixed(1)}<br>`+ `Biome: ${biomeAt(Math.floor(player.x),Math.floor(player.z))} Time: ${tod|0}:${(''+((tod%1*60)|0)).padStart(2,'0')}<br>`+ `Chunks: ${chunks.size} Mobs: ${mobs.length} Seed: ${SEED}`; } /* ---------------- Init + Main loop ---------------- */ let frame=0; const clock=new THREE.Clock(); function findSpawn(){ for(let r=0;r<200;r+=4){ for(let a=0;a<Math.PI*2;a+=.7){ const x=Math.floor(Math.sin(a)*r)+8,z=Math.floor(Math.cos(a)*r)+8; const h=groundH(x,z); if(h>SEA+1)return{x:x+.5,y:h+2,z:z+.5}; } } return{x:8.5,y:45,z:8.5}; } function init(){ const saveData=load(); if(saveData&&saveData.seed!==undefined){ SEED=saveData.seed; worldTime=saveData.time||DAY*.06; if(saveData.hotbar)hotbar=saveData.hotbar; for(const k in saveData.edits)editsByChunk.set(k,saveData.edits[k]); }else{ SEED=(Math.random()*0x7fffffff)|0; worldTime=DAY*.06; } perlin=makePerlin(SEED); genTextures(); setupScene(); setupInput(); buildHotbar();buildInventory();updateHearts();updateHandMesh(); // spawn position spawnPoint=findSpawn(); if(saveData&&saveData.p){ Object.assign(player,{x:saveData.p.x,y:saveData.p.y,z:saveData.p.z, yaw:saveData.p.yaw,pitch:saveData.p.pitch,hp:saveData.p.hp??20,fly:!!saveData.p.fly}); }else{ player.x=spawnPoint.x;player.y=spawnPoint.y;player.z=spawnPoint.z; } player.peakY=player.y;updateHearts(); // pre-generate spawn area synchronously $('loadingText').textContent='Generating terrain...'; const pcx=Math.floor(player.x/16),pcz=Math.floor(player.z/16); setTimeout(()=>{ for(let dx=-2;dx<=2;dx++)for(let dz=-2;dz<=2;dz++) if(!chunks.has(ckey(pcx+dx,pcz+dz)))genChunk(pcx+dx,pcz+dz); for(const k of[...dirty]){const c=chunks.get(k);if(c)meshChunk(c);dirty.delete(k);} // make sure player isn't inside terrain while(collides())player.y+=1; $('loadingText').textContent='World ready! Seed: '+SEED; const pb=$('playBtn');pb.disabled=false;pb.textContent=saveData?'Continue World':'Play'; clock.getDelta(); animate(); },50); setInterval(save,15000); addEventListener('beforeunload',save); } function animate(){ requestAnimationFrame(animate); frame++; const dt=clamp(clock.getDelta(),0,.1); if(playing&&!dead&&!invOpen&&document.pointerLockElement===renderer.domElement){ updatePlayer(dt); updateBreaking(dt); updateMobs(dt); } if(playing){ updateStream(); updateParticles(dt); updateDayNight(dt); updateDebug(dt); }else{ // gentle camera pan on title screen updateDayNight(dt*0); camera.position.set(player.x,player.y+EYE+6,player.z); camera.rotation.set(-.4,worldTime*.01+frame*.0005,0); updateStream(); } renderer.render(scene,camera); } init(); </script> </body> </html> 1 个帖子 - 1 位参与者 阅读完整话题
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>歌词搜索 & 格式转换 - LRCLib</title> <style> :root { --bg: #0b0b12; --surface: #161622; --surface2: #1e1e30; --border: #2a2a40; --text: #e0e0e0; --text2: #9090a8; --accent: #7c5cfc; --accent2: #a78bfa; --green: #34d399; --radius: 14px; --radius-sm: 8px; --radius-xs: 6px; } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; display: flex; justify-content: center; padding: 24px 16px; } .container { width: 100%; max-width: 960px; display: flex; flex-direction: column; gap: 20px; } .header { text-align: center; padding: 8px 0; } .header h1 { font-size: 1.9rem; font-weight: 700; letter-spacing: -0.5px; background: linear-gradient(135deg, #a78bfa 0%, #34d399 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .header .subtitle { color: var(--text2); font-size: 0.85rem; margin-top: 2px; } .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 22px; } .search-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } .search-row input { flex: 1; min-width: 200px; padding: 12px 16px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--surface2); color: var(--text); font-size: 0.95rem; outline: none; transition: border-color 0.2s; } .search-row input:focus { border-color: var(--accent); } .search-row input::placeholder { color: #555; } .btn { padding: 11px 20px; border-radius: var(--radius-sm); border: none; cursor: pointer; font-size: 0.9rem; font-weight: 600; transition: all 0.2s; white-space: nowrap; display: inline-flex; align-items: center; gap: 6px; letter-spacing: 0.2px; } .btn-primary { background: var(--accent); color: #fff; } .btn-primary:hover { background: #8f6fff; transform: translateY(-1px); box-shadow: 0 6px 24px rgba(124, 92, 252, 0.35); } .btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); } .btn-outline:hover { background: var(--surface2); border-color: #555; } .btn-sm { padding: 7px 14px; font-size: 0.8rem; border-radius: var(--radius-xs); } .btn-xs { padding: 5px 10px; font-size: 0.74rem; border-radius: 5px; } .btn-copy { background: #065f46; color: #d1fae5; border: 1px solid #059669; } .btn-copy:hover { background: #047857; box-shadow: 0 4px 16px rgba(5, 150, 105, 0.3); } .btn-download { background: #1e3a5f; color: #bfdbfe; border: 1px solid #3b82f6; } .btn-download:hover { background: #1e40af; box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3); } .results-panel { display: none; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; max-height: 340px; overflow-y: auto; } .results-panel.active { display: block; } .results-panel .section-label { font-size: 0.78rem; color: var(--text2); text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; } .result-item { display: flex; align-items: center; justify-content: space-between; padding: 11px 14px; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.15s; gap: 12px; flex-wrap: wrap; } .result-item:hover { background: var(--surface2); } .result-item+.result-item { border-top: 1px solid rgba(255, 255, 255, 0.04); } .result-info { flex: 1; min-width: 0; } .result-info .track { font-weight: 600; font-size: 0.98rem; color: #f0f0f0; } .result-info .artist { font-size: 0.83rem; color: var(--text2); } .result-info .album { font-size: 0.76rem; color: #666; } .result-meta { font-size: 0.75rem; color: #555; white-space: nowrap; } .placeholder-text { text-align: center; color: var(--text2); padding: 28px; font-size: 0.9rem; } .loading-indicator { text-align: center; padding: 28px; color: var(--text2); display: flex; align-items: center; justify-content: center; gap: 10px; } .spinner { width: 18px; height: 18px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .lyrics-panel { display: none; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } .lyrics-panel.active { display: block; } .lyrics-topbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; padding: 18px 22px; border-bottom: 1px solid var(--border); } .lyrics-topbar .song-info h2 { font-size: 1.25rem; font-weight: 700; } .lyrics-topbar .song-info .meta-line { font-size: 0.84rem; color: var(--text2); margin-top: 2px; } .lyrics-topbar .song-info .meta-line .artist-name { color: var(--accent2); font-weight: 500; } .format-tabs { display: flex; gap: 4px; padding: 12px 22px; border-bottom: 1px solid var(--border); flex-wrap: wrap; background: rgba(0, 0, 0, 0.15); } .format-tab { padding: 9px 18px; border-radius: 22px; border: 1px solid transparent; background: transparent; color: var(--text2); cursor: pointer; font-size: 0.84rem; font-weight: 500; transition: all 0.2s; letter-spacing: 0.3px; } .format-tab.active { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; } .format-tab:hover:not(.active) { border-color: #555; color: #d0d0d0; } .lyrics-body { padding: 20px 22px; max-height: 520px; overflow-y: auto; background: var(--surface2); font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'PingFang SC', monospace; font-size: 0.88rem; line-height: 1.75; white-space: pre-wrap; color: #c8c8d8; } .lyrics-body .lrc-tag { color: var(--accent2); font-weight: 600; } .lyrics-body .ass-header { color: #fbbf24; } .lyrics-body .ass-style { color: #60a5fa; } .lyrics-body .srt-index { color: #94a3b8; } .lyrics-body .srt-time { color: #34d399; } .lyrics-body .vtt-header-line { color: #fbbf24; } .lyrics-body .vtt-cue-time { color: #34d399; } .action-bar { display: flex; gap: 10px; padding: 14px 22px; border-top: 1px solid var(--border); flex-wrap: wrap; align-items: center; background: rgba(0, 0, 0, 0.1); } .action-bar .action-label { font-size: 0.76rem; color: var(--text2); text-transform: uppercase; letter-spacing: 1.2px; font-weight: 600; margin-right: 4px; } .action-bar .divider { width: 1px; height: 20px; background: var(--border); margin: 0 6px; } .toast { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); padding: 12px 26px; border-radius: 30px; font-weight: 600; font-size: 0.88rem; z-index: 999; opacity: 0; pointer-events: none; transition: opacity 0.3s; letter-spacing: 0.3px; } .toast.show { opacity: 1; } .toast.success { background: #065f46; color: #d1fae5; box-shadow: 0 8px 28px rgba(5, 150, 105, 0.35); } .toast.error { background: #7f1d1d; color: #fecaca; box-shadow: 0 8px 28px rgba(220, 38, 38, 0.35); } ::-webkit-scrollbar { width: 5px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { background: #555; } @media (max-width: 640px) { .search-row { flex-direction: column; align-items: stretch; } .search-row input { min-width: 100%; } .btn { justify-content: center; } .lyrics-topbar { flex-direction: column; align-items: flex-start; } .action-bar .divider { display: none; } .format-tabs { gap: 2px; } .format-tab { padding: 7px 12px; font-size: 0.78rem; } } </style> </head> <body> <div class="container"> <div class="header"> <h1>🎵 歌词搜索 & 格式转换</h1> <p class="subtitle">基于 LRCLib · LRC / SRT / ASS / VTT 四种字幕歌词格式</p> </div> <div class="card"> <div class="search-row"> <input type="text" id="searchInput" placeholder="搜索歌曲名或歌手名… 或输入「歌曲名 - 歌手名」精确查找" autocomplete="off"> <button class="btn btn-primary" id="searchBtn">🔍 搜索</button> <button class="btn btn-outline btn-sm" id="directBtn">🎯 精确获取</button> </div> </div> <div class="results-panel" id="resultsPanel"> <div class="section-label">📋 搜索结果 <span id="resultCount"></span></div> <div id="resultsList"></div> </div> <div class="lyrics-panel" id="lyricsPanel"> <div class="lyrics-topbar"> <div class="song-info"> <h2 id="songTitle">—</h2> <div class="meta-line"> <span class="artist-name" id="songArtist">—</span> <span style="margin:0 6px;color:#555;">·</span> <span id="songAlbum">—</span> <span style="margin:0 6px;color:#555;">·</span> <span id="songDuration">—</span> </div> </div> </div> <div class="format-tabs"> <button class="format-tab active" data-format="lrc">📝 LRC 歌词</button> <button class="format-tab" data-format="srt">🎬 SRT 字幕</button> <button class="format-tab" data-format="ass">🎨 ASS 字幕</button> <button class="format-tab" data-format="vtt">🌐 VTT 字幕</button> </div> <div class="lyrics-body" id="lyricsBody"></div> <div class="action-bar"> <span class="action-label">当前格式</span> <button class="btn btn-copy btn-sm" id="copyBtn">📋 复制</button> <div class="divider"></div> <span class="action-label" style="color:#bfdbfe;">文件导出</span> <button class="btn btn-download btn-sm" id="downloadLrcBtn">⬇ .lrc</button> <button class="btn btn-download btn-sm" id="downloadSrtBtn">⬇ .srt</button> <button class="btn btn-download btn-sm" id="downloadAssBtn">⬇ .ass</button> <button class="btn btn-download btn-sm" id="downloadVttBtn">⬇ .vtt</button> </div> </div> </div> <div class="toast" id="toast"></div> <script> (function() { // ── DOM refs ──────────────────────────── var searchInput = document.getElementById('searchInput'); var searchBtn = document.getElementById('searchBtn'); var directBtn = document.getElementById('directBtn'); var resultsPanel = document.getElementById('resultsPanel'); var resultsList = document.getElementById('resultsList'); var resultCount = document.getElementById('resultCount'); var lyricsPanel = document.getElementById('lyricsPanel'); var lyricsBody = document.getElementById('lyricsBody'); var songTitle = document.getElementById('songTitle'); var songArtist = document.getElementById('songArtist'); var songAlbum = document.getElementById('songAlbum'); var songDuration = document.getElementById('songDuration'); var copyBtn = document.getElementById('copyBtn'); var downloadLrcBtn = document.getElementById('downloadLrcBtn'); var downloadSrtBtn = document.getElementById('downloadSrtBtn'); var downloadAssBtn = document.getElementById('downloadAssBtn'); var downloadVttBtn = document.getElementById('downloadVttBtn'); var toastEl = document.getElementById('toast'); var formatTabs = document.querySelectorAll('.format-tab'); // ── State ──────────────────────────────── var currentSong = null; var currentFormat = 'lrc'; var searchResults = []; // ── Toast ──────────────────────────────── var toastTimer; function showToast(msg, isError) { clearTimeout(toastTimer); toastEl.textContent = msg; toastEl.className = 'toast show ' + (isError ? 'error' : 'success'); toastTimer = setTimeout(function() { toastEl.className = 'toast'; }, 2200); } // ── API ────────────────────────────────── function apiSearch(query) { return fetch('https://lrclib.net/api/search?q=' + encodeURIComponent(query)) .then(function(resp) { if (!resp.ok) throw new Error('搜索失败 (HTTP ' + resp.status + ')'); return resp.json(); }); } function apiGetLyrics(track, artist) { return fetch( 'https://lrclib.net/api/get?track_name=' + encodeURIComponent(track) + '&artist_name=' + encodeURIComponent(artist) ) .then(function(resp) { if (!resp.ok) throw new Error('获取失败 (HTTP ' + resp.status + ')'); return resp.text(); }) .then(function(text) { if (!text) throw new Error('未找到该歌曲'); return JSON.parse(text); }); } // ── LRC Parsing ────────────────────────── function parseLrcTimestamps(syncedLyrics) { var items = []; if (!syncedLyrics) return items; var regex = /\[(\d{2}):(\d{2})\.(\d{2,3})\]\s*(.*)/g; var m; while ((m = regex.exec(syncedLyrics)) !== null) { var min = parseInt(m[1], 10); var sec = parseInt(m[2], 10); var fracRaw = m[3]; var ms = fracRaw.length === 2 ? parseInt(fracRaw, 10) * 10 : parseInt(fracRaw, 10); var totalMs = min * 60000 + sec * 1000 + ms; items.push({ timeMs: totalMs, text: (m[4] || '').trim() }); } return items; } function pad2(n) { return String(n).padStart(2, '0'); } function pad3(n) { return String(n).padStart(3, '0'); } function msToLrc(ms) { var min = Math.floor(ms / 60000); var sec = Math.floor((ms % 60000) / 1000); var cs = Math.floor((ms % 1000) / 10); return '[' + pad2(min) + ':' + pad2(sec) + '.' + pad2(cs) + ']'; } function msToSrt(ms) { var h = Math.floor(ms / 3600000); var m = Math.floor((ms % 3600000) / 60000); var s = Math.floor((ms % 60000) / 1000); var milli = ms % 1000; return pad2(h) + ':' + pad2(m) + ':' + pad2(s) + ',' + pad3(milli); } function msToAss(ms) { var h = Math.floor(ms / 3600000); var m = Math.floor((ms % 3600000) / 60000); var s = Math.floor((ms % 60000) / 1000); var cs = Math.floor((ms % 1000) / 10); return h + ':' + pad2(m) + ':' + pad2(s) + '.' + pad2(cs); } function msToVtt(ms) { var h = Math.floor(ms / 3600000); var m = Math.floor((ms % 3600000) / 60000); var s = Math.floor((ms % 60000) / 1000); var milli = ms % 1000; return pad2(h) + ':' + pad2(m) + ':' + pad2(s) + '.' + pad3(milli); } // ── Format Generators ──────────────────── function generateLRC(song) { var items = parseLrcTimestamps(song.syncedLyrics); var lines = []; lines.push('[ti:' + (song.trackName || '') + ']'); lines.push('[ar:' + (song.artistName || '') + ']'); if (song.albumName) lines.push('[al:' + song.albumName + ']'); if (song.duration) { var m = Math.floor(song.duration / 60); var s = Math.floor(song.duration % 60); lines.push('[length:' + pad2(m) + ':' + pad2(s) + ']'); } lines.push('[by:LRC Generator]'); lines.push(''); if (items.length > 0) { for (var i = 0; i < items.length; i++) { lines.push(msToLrc(items[i].timeMs) + (items[i].text || '♪')); } } else if (song.plainLyrics) { var plainLines = song.plainLyrics.split('\n'); for (var j = 0; j < plainLines.length; j++) { lines.push(plainLines[j].trim()); } } return lines.join('\n'); } function generateSRT(song) { var items = parseLrcTimestamps(song.syncedLyrics); if (items.length === 0) { if (song.plainLyrics) { return '1\n00:00:00,000 --> 00:03:00,000\n' + song.plainLyrics.trim() + '\n'; } return ''; } var lines = []; for (var i = 0; i < items.length; i++) { var cur = items[i]; var next = items[i + 1]; var endMs = next ? next.timeMs : cur.timeMs + 3000; lines.push(String(i + 1)); lines.push(msToSrt(cur.timeMs) + ' --> ' + msToSrt(endMs)); lines.push(cur.text || '♪'); lines.push(''); } return lines.join('\n').trim(); } function generateASS(song) { var items = parseLrcTimestamps(song.syncedLyrics); var title = song.trackName || 'Unknown'; var artist = song.artistName || 'Unknown'; var lines = []; lines.push('[Script Info]'); lines.push('Title: ' + title); lines.push('Original Script: ' + artist); lines.push('ScriptType: v4.00+'); lines.push('Collisions: Normal'); lines.push('PlayDepth: 0'); lines.push(''); lines.push('[V4+ Styles]'); lines.push( 'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding'); lines.push( 'Style: Default,Microsoft YaHei,36,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,2,1,2,30,30,30,1'); lines.push(''); lines.push('[Events]'); lines.push('Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text'); if (items.length > 0) { for (var i = 0; i < items.length; i++) { var cur = items[i]; var next = items[i + 1]; var endMs = next ? next.timeMs : cur.timeMs + 3000; var text = (cur.text || '♪').replace(/\n/g, '\\N'); lines.push('Dialogue: 0,' + msToAss(cur.timeMs) + ',' + msToAss(endMs) + ',Default,,0,0,0,,' + text); } } else if (song.plainLyrics) { var plainLines = song.plainLyrics.split('\n').filter(function(l) { return l.trim(); }); var duration = (song.duration || 180) * 1000; var eachMs = Math.floor(duration / Math.max(plainLines.length, 1)); for (var j = 0; j < plainLines.length; j++) { var start = j * eachMs; var end = start + eachMs; var t = plainLines[j].trim().replace(/\n/g, '\\N'); lines.push('Dialogue: 0,' + msToAss(start) + ',' + msToAss(end) + ',Default,,0,0,0,,' + t); } } return lines.join('\n'); } function generateVTT(song) { var items = parseLrcTimestamps(song.syncedLyrics); var lines = []; lines.push('WEBVTT'); lines.push(''); if (items.length > 0) { for (var i = 0; i < items.length; i++) { var cur = items[i]; var next = items[i + 1]; var endMs = next ? next.timeMs : cur.timeMs + 3000; lines.push(msToVtt(cur.timeMs) + ' --> ' + msToVtt(endMs)); lines.push(cur.text || '♪'); lines.push(''); } } else if (song.plainLyrics) { var plainLines = song.plainLyrics.split('\n').filter(function(l) { return l.trim(); }); var duration = (song.duration || 180) * 1000; var eachMs = Math.floor(duration / Math.max(plainLines.length, 1)); for (var j = 0; j < plainLines.length; j++) { var start = j * eachMs; var end = start + eachMs; lines.push(msToVtt(start) + ' --> ' + msToVtt(end)); lines.push(plainLines[j].trim()); lines.push(''); } } return lines.join('\n').trim(); } function getFormattedContent(format, song) { if (!song) return ''; switch (format) { case 'lrc': return generateLRC(song); case 'srt': return generateSRT(song); case 'ass': return generateASS(song); case 'vtt': return generateVTT(song); default: return ''; } } function escapeHtml(str) { var div = document.createElement('div'); div.textContent = str; return div.innerHTML; } function getHighlightedHtml(format, song) { var raw = getFormattedContent(format, song); if (!raw) return '<span style="color:#666;">暂无内容</span>'; var esc = escapeHtml(raw); switch (format) { case 'lrc': esc = esc.replace( /^(\[ti:.*\]|\[ar:.*\]|\[al:.*\]|\[length:.*\]|\[by:.*\])$/gm, '<span class="lrc-tag">$1</span>' ); esc = esc.replace( /^(\[\d{2}:\d{2}\.\d{2}\])/gm, '<span class="lrc-tag">$1</span>' ); break; case 'srt': esc = esc.replace(/^(\d+)$/gm, '<span class="srt-index">$1</span>'); esc = esc.replace( /^(\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3})$/gm, '<span class="srt-time">$1</span>' ); break; case 'ass': esc = esc.replace(/^(\[.*\])$/gm, '<span class="ass-header">$1</span>'); esc = esc.replace(/^(Style:.*)$/gm, '<span class="ass-style">$1</span>'); esc = esc.replace(/^(Format:.*)$/gm, '<span class="ass-style">$1</span>'); break; case 'vtt': esc = esc.replace(/^(WEBVTT)$/gm, '<span class="vtt-header-line">$1</span>'); esc = esc.replace( /^(\d{2}:\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}:\d{2}\.\d{3})$/gm, '<span class="vtt-cue-time">$1</span>' ); break; } return esc; } // ── Render ──────────────────────────────── function renderLyrics(song) { currentSong = song; songTitle.textContent = song.trackName || '未知歌曲'; songArtist.textContent = song.artistName || '未知歌手'; songAlbum.textContent = song.albumName || ''; songDuration.textContent = formatDuration(song.duration); lyricsPanel.classList.add('active'); updateLyricsDisplay(); lyricsPanel.scrollIntoView({ behavior: 'smooth', block: 'center' }); } function updateLyricsDisplay() { lyricsBody.innerHTML = getHighlightedHtml(currentFormat, currentSong); } function formatDuration(sec) { if (!sec && sec !== 0) return ''; var m = Math.floor(sec / 60); var s = Math.floor(sec % 60); return m + ':' + pad2(s); } // ── Tab Switching ───────────────────────── for (var t = 0; t < formatTabs.length; t++) { formatTabs[t].addEventListener('click', function() { for (var i = 0; i < formatTabs.length; i++) { formatTabs[i].classList.remove('active'); } this.classList.add('active'); currentFormat = this.dataset.format; updateLyricsDisplay(); }); } // ── Copy ────────────────────────────────── copyBtn.addEventListener('click', function() { if (!currentSong) { showToast('请先搜索并选择一首歌曲', true); return; } var content = getFormattedContent(currentFormat, currentSong); var labels = { lrc: 'LRC', srt: 'SRT', ass: 'ASS', vtt: 'VTT' }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(content).then(function() { showToast('✅ 已复制 ' + labels[currentFormat] + ' 内容'); }).catch(function() { fallbackCopy(content); showToast('✅ 已复制 ' + labels[currentFormat] + ' 内容'); }); } else { fallbackCopy(content); showToast('✅ 已复制 ' + labels[currentFormat] + ' 内容'); } }); function fallbackCopy(text) { var ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;opacity:0;'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } // ── Download ────────────────────────────── function downloadFile(content, filename, mime) { var blob = new Blob([content], { type: mime }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function safeFilename(song, ext) { var t = (song.trackName || 'unknown').replace(/[\\/:*?"<>|]/g, '_'); var a = (song.artistName || 'unknown').replace(/[\\/:*?"<>|]/g, '_'); return a + ' - ' + t + '.' + ext; } function doDownload(format) { if (!currentSong) { showToast('请先选择歌曲', true); return; } var content = getFormattedContent(format, currentSong); if (!content.trim()) { showToast('没有可导出的内容', true); return; } var mimes = { lrc: 'text/plain', srt: 'text/srt', ass: 'text/plain', vtt: 'text/vtt' }; downloadFile(content, safeFilename(currentSong, format), mimes[format] || 'text/plain'); showToast('⬇ 已下载 ' + format.toUpperCase() + ' 文件'); } downloadLrcBtn.addEventListener('click', function() { doDownload('lrc'); }); downloadSrtBtn.addEventListener('click', function() { doDownload('srt'); }); downloadAssBtn.addEventListener('click', function() { doDownload('ass'); }); downloadVttBtn.addEventListener('click', function() { doDownload('vtt'); }); // ── Search ──────────────────────────────── function doSearch(query) { if (!query || !query.trim()) { showToast('请输入搜索关键词', true); return; } resultsList.innerHTML = '<div class="loading-indicator"><span class="spinner"></span>搜索中…</div>'; resultsPanel.classList.add('active'); apiSearch(query.trim()) .then(function(results) { searchResults = results; resultCount.textContent = '(' + results.length + ' 条)'; if (results.length === 0) { resultsList.innerHTML = '<div class="placeholder-text">😕 未找到匹配的歌曲,换个关键词试试</div>'; return; } var html = ''; for (var i = 0; i < results.length; i++) { var r = results[i]; html += '<div class="result-item" data-index="' + i + '">'; html += '<div class="result-info">'; html += '<div class="track">' + escapeHtml(r.trackName || r.name || '未知') + '</div>'; html += '<div class="artist">' + escapeHtml(r.artistName || '未知歌手') + '</div>'; if (r.albumName) { html += '<div class="album">💿 ' + escapeHtml(r.albumName) + '</div>'; } html += '</div>'; html += '<div class="result-meta">' + formatDuration(r.duration) + '</div>'; html += '<button class="btn btn-outline btn-xs pick-btn" data-index="' + i + '">选择</button>'; html += '</div>'; } resultsList.innerHTML = html; var items = resultsList.querySelectorAll('.result-item'); for (var j = 0; j < items.length; j++) { (function(idx) { items[j].addEventListener('click', function(e) { if (e.target.closest('.pick-btn')) return; loadResult(idx); }); })(j); } var btns = resultsList.querySelectorAll('.pick-btn'); for (var k = 0; k < btns.length; k++) { (function(idx) { btns[k].addEventListener('click', function(e) { e.stopPropagation(); loadResult(idx); }); })(k); } }) .catch(function(err) { resultsList.innerHTML = '<div class="placeholder-text">❌ ' + escapeHtml(err.message) + '</div>'; resultCount.textContent = '(0 条)'; }); } function loadResult(idx) { var song = searchResults[idx]; if (!song) return; var tn = song.trackName || song.name; var an = song.artistName || ''; var allItems = resultsList.querySelectorAll('.result-item'); for (var i = 0; i < allItems.length; i++) { allItems[i].style.opacity = '0.4'; } var target = resultsList.querySelector('[data-index="' + idx + '"]'); if (target) target.style.opacity = '1'; apiGetLyrics(tn, an) .then(function(data) { renderLyrics(data); }) .catch(function(err) { showToast('❌ ' + err.message, true); }) .finally(function() { for (var j = 0; j < allItems.length; j++) { allItems[j].style.opacity = '1'; } }); } // ── Event Bindings ──────────────────────── searchBtn.onclick = function() { doSearch(searchInput.value); }; searchInput.onkeydown = function(e) { if (e.key === 'Enter') { doSearch(searchInput.value); } }; directBtn.onclick = function() { var q = searchInput.value.trim(); if (!q) { showToast('请输入「歌曲名 - 歌手名」', true); return; } var seps = [' - ', '-', ' – ', '–', ' | ', '|', ':', ':']; var track = ''; var artist = ''; for (var i = 0; i < seps.length; i++) { if (q.indexOf(seps[i]) !== -1) { var parts = q.split(seps[i]); track = parts[0].trim(); artist = parts.slice(1).join(seps[i]).trim(); break; } } if (!track) { track = prompt('请输入歌曲名:', q); if (!track) return; artist = prompt('请输入歌手名(可选):', '') || ''; } resultsList.innerHTML = '<div class="loading-indicator"><span class="spinner"></span>获取中…</div>'; resultsPanel.classList.add('active'); apiGetLyrics(track, artist) .then(function(data) { searchResults = [data]; resultCount.textContent = '(1 条)'; resultsList.innerHTML = '<div class="result-item" style="opacity:1;">' + '<div class="result-info">' + '<div class="track">' + escapeHtml(data.trackName || data.name || '未知') + '</div>' + '<div class="artist">' + escapeHtml(data.artistName || '未知歌手') + '</div>' + (data.albumName ? '<div class="album">💿 ' + escapeHtml(data.albumName) + '</div>' : '') + '</div>' + '<div class="result-meta">' + formatDuration(data.duration) + '</div>' + '</div>'; renderLyrics(data); }) .catch(function(err) { resultsList.innerHTML = '<div class="placeholder-text">❌ ' + escapeHtml(err.message) + '</div>'; resultCount.textContent = '(0 条)'; showToast('❌ ' + err.message, true); }); }; // ── Init ────────────────────────────────── searchInput.focus(); })(); </script> </body> </html> 1 个帖子 - 1 位参与者 阅读完整话题
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>FLAC 重命名工具 · 读取内嵌元数据</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { background: #f5f0e8; font-family: 'Segoe UI', system-ui, sans-serif; min-height: 100vh; display: flex; justify-content: center; align-items: center; padding: 1.5rem; } .container { max-width: 850px; width: 100%; background: #fffdf7; border-radius: 1.8rem; box-shadow: 0 20px 40px rgba(0,0,0,0.1); padding: 2rem; } h1 { font-size: 2rem; color: #4a3724; display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.3rem; } .badge { background: #d4b896; color: #2d1f0e; font-size: 0.85rem; padding: 0.3rem 1rem; border-radius: 20px; font-weight: 600; } .desc { color: #6b5d4b; margin-bottom: 1.5rem; font-size: 0.95rem; border-left: 3px solid #c9a87c; padding-left: 1rem; } .drop-area { background: #faf7f1; border: 2px dashed #c8b28b; border-radius: 1.5rem; padding: 2.5rem; text-align: center; cursor: pointer; transition: all 0.2s; margin-bottom: 1.5rem; } .drop-area:hover, .drop-area.active { border-color: #a0845c; background: #f5ede0; box-shadow: 0 0 0 4px rgba(180,140,90,0.1); } .drop-area .icon { font-size: 3rem; margin-bottom: 0.5rem; } .drop-area .main-text { font-weight: 600; color: #4d3a24; font-size: 1.1rem; } .drop-area .sub-text { color: #8b7a62; font-size: 0.9rem; margin-top: 0.2rem; } input[type="file"] { display: none; } .file-panel { background: #fefcf8; border: 1px solid #e3d5bd; border-radius: 1.2rem; padding: 1rem; max-height: 380px; overflow-y: auto; margin: 1.2rem 0; display: none; } .file-row { display: flex; align-items: center; gap: 0.8rem; padding: 0.7rem 0.5rem; border-bottom: 1px solid #efe4d0; flex-wrap: wrap; } .file-row:last-child { border-bottom: none; } .orig { font-family: 'Consolas', 'Monaco', monospace; background: #f3ecdd; padding: 0.3rem 0.8rem; border-radius: 20px; font-size: 0.85rem; color: #5c4a30; word-break: break-all; flex: 1; min-width: 130px; } .arrow { color: #b39260; font-weight: bold; font-size: 1.2rem; } .newname { font-family: 'Consolas', 'Monaco', monospace; background: #e2edda; padding: 0.3rem 0.8rem; border-radius: 20px; font-size: 0.85rem; color: #2d4a1e; font-weight: 600; word-break: break-all; flex: 1; min-width: 130px; text-align: right; } .newname.missing { background: #ffe8e0; color: #a04030; font-style: italic; } .meta-detail { font-size: 0.7rem; color: #8b7356; background: #f9f4ea; padding: 0.15rem 0.6rem; border-radius: 12px; white-space: nowrap; } .actions { display: flex; gap: 0.8rem; flex-wrap: wrap; align-items: center; } button { padding: 0.8rem 1.8rem; border-radius: 2rem; border: 1px solid #d4bc92; background: #f1e7d4; color: #4d3a22; font-weight: 600; cursor: pointer; font-size: 0.95rem; transition: 0.2s; display: flex; align-items: center; gap: 0.3rem; } button:hover:not(:disabled) { background: #e5d3b0; } button.primary { background: #c7a16b; border-color: #9c7a4a; color: #fffdf5; box-shadow: 0 4px 12px rgba(160,120,50,0.2); } button.primary:hover:not(:disabled) { background: #b38845; } button:disabled { opacity: 0.45; cursor: not-allowed; } .status { margin-left: auto; font-size: 0.9rem; color: #6b5d48; background: #f6f1e6; padding: 0.4rem 1.2rem; border-radius: 20px; } .footer { margin-top: 1rem; font-size: 0.8rem; color: #9b8b74; text-align: center; } </style> </head> <body> <div class="container"> <h1>🎵 FLAC 元数据重命名 <span class="badge">标题-作曲家</span></h1> <div class="desc">读取 FLAC 文件内嵌的歌曲标题 (TITLE) 和作曲家 (COMPOSER/ARTIST) 标签,自动重命名为 "标题-作曲家.flac"</div> <div class="drop-area" id="dropZone"> <div class="icon">📂</div> <div class="main-text">点击选择或拖拽 FLAC 文件</div> <div class="sub-text">支持批量 · 读取内嵌元数据标签</div> </div> <input type="file" id="fileInput" accept=".flac,audio/flac" multiple> <div class="file-panel" id="filePanel"> <div id="fileList"></div> </div> <div class="actions"> <button id="clearBtn" disabled>🗑️ 清空</button> <button id="renameBtn" class="primary" disabled>💾 下载重命名文件</button> <span class="status" id="status">等待添加 FLAC 文件...</span> </div> <div class="footer">* 浏览器安全限制:通过下载方式生成新文件名,原文件不会被修改</div> </div> <script> (function() { const dropZone = document.getElementById('dropZone'); const fileInput = document.getElementById('fileInput'); const filePanel = document.getElementById('filePanel'); const fileListDiv = document.getElementById('fileList'); const clearBtn = document.getElementById('clearBtn'); const renameBtn = document.getElementById('renameBtn'); const statusEl = document.getElementById('status'); let filesData = []; // { file, title, composer, newName, error } // ========== 解析 FLAC 内嵌元数据 (Vorbis Comment) ========== async function readFlacMetadata(file) { return new Promise((resolve) => { const reader = new FileReader(); reader.onload = (e) => { try { const buf = e.target.result; const view = new DataView(buf); if (buf.byteLength < 4) return resolve({ title: null, composer: null, error: '文件过小' }); const magic = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3)); if (magic !== 'fLaC') return resolve({ title: null, composer: null, error: '非FLAC文件' }); let offset = 4; let lastBlock = false; let title = null; let composer = null; while (offset < buf.byteLength && !lastBlock) { if (offset + 4 > buf.byteLength) break; const header = view.getUint8(offset); lastBlock = (header & 0x80) !== 0; const blockType = header & 0x7F; const blockSize = (view.getUint8(offset+1) << 16) | (view.getUint8(offset+2) << 8) | view.getUint8(offset+3); offset += 4; if (blockType === 4 && offset + blockSize <= buf.byteLength) { // VORBIS_COMMENT 块 const block = new Uint8Array(buf, offset, blockSize); const dec = new TextDecoder('utf-8'); if (block.length < 4) { offset += blockSize; continue; } const vendorLen = block[0] | (block[1]<<8) | (block[2]<<16) | (block[3]<<24); let pos = 4 + vendorLen; if (pos + 4 > block.length) { offset += blockSize; continue; } const numComments = block[pos] | (block[pos+1]<<8) | (block[pos+2]<<16) | (block[pos+3]<<24); pos += 4; for (let i = 0; i < numComments; i++) { if (pos + 4 > block.length) break; const commentLen = block[pos] | (block[pos+1]<<8) | (block[pos+2]<<16) | (block[pos+3]<<24); pos += 4; if (pos + commentLen > block.length) break; const commentStr = dec.decode(block.slice(pos, pos + commentLen)); pos += commentLen; const eqIdx = commentStr.indexOf('='); if (eqIdx > 0) { const key = commentStr.substring(0, eqIdx).toUpperCase().trim(); const val = commentStr.substring(eqIdx + 1).trim(); if (key === 'TITLE' && !title) title = val; // 优先 COMPOSER,其次 ARTIST if (key === 'COMPOSER' && !composer) composer = val; if (key === 'ARTIST' && !composer) composer = val; } } // 找到注释块即可退出 break; } offset += blockSize; } resolve({ title: title || null, composer: composer || null, error: null }); } catch (err) { resolve({ title: null, composer: null, error: err.message }); } }; reader.onerror = () => resolve({ title: null, composer: null, error: '读取失败' }); reader.readAsArrayBuffer(file); }); } // ========== 生成新文件名 ========== function makeNewName(title, composer, origName) { const sanitize = (s) => s.replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, ' ').trim().substring(0, 200); const fallback = origName.replace(/\.flac$/i, '') || 'unknown'; const t = (title && title.trim()) ? sanitize(title) : sanitize(fallback); const c = (composer && composer.trim()) ? sanitize(composer) : '未知作曲家'; return `${t}-${c}.flac`; } // ========== 渲染文件列表 ========== function render() { fileListDiv.innerHTML = ''; if (filesData.length === 0) { filePanel.style.display = 'none'; clearBtn.disabled = true; renameBtn.disabled = true; setStatus('📭 暂无文件'); return; } filePanel.style.display = 'block'; clearBtn.disabled = false; renameBtn.disabled = false; filesData.forEach(item => { const row = document.createElement('div'); row.className = 'file-row'; const origSpan = document.createElement('span'); origSpan.className = 'orig'; origSpan.textContent = item.file.name; const arrow = document.createElement('span'); arrow.className = 'arrow'; arrow.textContent = '→'; const newSpan = document.createElement('span'); newSpan.className = 'newname'; if (item.error && !item.title && !item.composer) { newSpan.classList.add('missing'); newSpan.textContent = '⚠ 元数据缺失'; } else { newSpan.textContent = item.newName; } // 显示读取到的标签详情 const metaDetail = document.createElement('span'); metaDetail.className = 'meta-detail'; const t = item.title || '—'; const c = item.composer || '—'; metaDetail.textContent = `TITLE: ${t} | COMPOSER: ${c}`; row.appendChild(origSpan); row.appendChild(arrow); row.appendChild(newSpan); row.appendChild(metaDetail); fileListDiv.appendChild(row); }); const valid = filesData.filter(f => f.title || f.composer).length; setStatus(`📋 ${filesData.length} 个文件 · ${valid} 个可重命名`); } function setStatus(msg) { statusEl.textContent = msg; } // ========== 处理添加文件 ========== async function addFiles(fileList) { const flacFiles = Array.from(fileList).filter(f => f.name.toLowerCase().endsWith('.flac')); if (flacFiles.length === 0) { setStatus('❌ 请选择 .flac 文件'); return; } setStatus('🔍 正在读取内嵌元数据...'); renameBtn.disabled = true; clearBtn.disabled = true; for (const file of flacFiles) { const meta = await readFlacMetadata(file); const newName = makeNewName(meta.title, meta.composer, file.name); filesData.push({ file, title: meta.title, composer: meta.composer, newName, error: meta.error }); } // 去重 const seen = new Map(); filesData = filesData.filter(item => { const key = `${item.file.name}|${item.file.size}|${item.file.lastModified}`; if (seen.has(key)) return false; seen.set(key, true); return true; }); render(); setStatus('✅ 元数据读取完成'); } // ========== 执行下载重命名 ========== async function doRename() { const valid = filesData.filter(f => f.title || f.composer); if (valid.length === 0) { setStatus('⚠️ 没有可重命名的文件'); return; } setStatus('⏳ 正在下载重命名文件...'); renameBtn.disabled = true; clearBtn.disabled = true; for (const item of valid) { const blob = new Blob([item.file], { type: 'audio/flac' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = item.newName; document.body.appendChild(a); a.click(); document.body.removeChild(a); await new Promise(r => setTimeout(r, 150)); URL.revokeObjectURL(url); } setStatus('🎉 下载完成!原文件未被修改'); renameBtn.disabled = false; clearBtn.disabled = false; } function clearAll() { filesData = []; fileInput.value = ''; render(); setStatus('🧹 已清空'); } // ========== 事件绑定 ========== dropZone.addEventListener('click', () => fileInput.click()); dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('active'); }); dropZone.addEventListener('dragleave', e => { e.preventDefault(); dropZone.classList.remove('active'); }); dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('active'); if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files); }); fileInput.addEventListener('change', e => { if (e.target.files.length) addFiles(e.target.files); }); clearBtn.addEventListener('click', clearAll); renameBtn.addEventListener('click', doRename); render(); setStatus('📎 选择或拖入 FLAC 文件以读取内嵌元数据'); })(); </script> </body> </html> 1 个帖子 - 1 位参与者 阅读完整话题
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <title>五子棋 AI 对战</title> <style> :root { --bg-color: #f5f6fa; --board-color: #e4b980; --line-color: #634d31; --primary-color: #4a90e2; } * { box-sizing: border-box; margin: 0; padding: 0; user-select: none; -webkit-user-select: none; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background-color: var(--bg-color); display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; padding: 10px; } .container { width: 100%; max-width: 500px; display: flex; flex-direction: column; align-items: center; gap: 15px; } h1 { font-size: 1.5rem; color: #333; font-weight: 600; } .status { font-size: 1.1rem; font-weight: bold; color: var(--primary-color); height: 24px; } .board-wrapper { width: 100%; aspect-ratio: 1 / 1; background-color: var(--board-color); border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); padding: 12px; position: relative; } canvas { width: 100%; height: 100%; display: block; cursor: pointer; } .btn { background-color: var(--primary-color); color: white; border: none; padding: 10px 24px; font-size: 1rem; border-radius: 20px; cursor: pointer; box-shadow: 0 4px 12px rgba(74, 144, 226, 0.3); transition: all 0.2s ease; } .btn:active { transform: scale(0.95); box-shadow: 0 2px 6px rgba(74, 144, 226, 0.3); } </style> </head> <body> <div class="container"> <h1>五子棋 AI 对战</h1> <div class="status" id="status-text">你是黑棋,请落子</div> <div class="board-wrapper"> <canvas id="gobang"></canvas> </div> <button class="btn" onclick="restartGame()">重新开始</button> </div> <script> const canvas = document.getElementById('gobang'); const ctx = canvas.getContext('2d'); const statusText = document.getElementById('status-text'); const GRID_SIZE = 15; let cellSize = 0; // ===== 修复点 1:直接初始化为 15×15 的二维零矩阵 ===== let board = Array(GRID_SIZE).fill().map(() => Array(GRID_SIZE).fill(0)); let gameOver = false; let isAiTurn = false; let lastMove = null; let haloAngle = 0; function initCanvas() { const rect = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); cellSize = rect.width / (GRID_SIZE + 1); // ===== 修复点 2:移除这里的 render(),避免在 board 未就绪时渲染 ===== // 渲染工作交给 restartGame() 或动画循环 } function restartGame() { board = Array(GRID_SIZE).fill().map(() => Array(GRID_SIZE).fill(0)); gameOver = false; isAiTurn = false; lastMove = null; statusText.innerText = "你是黑棋,请落子"; statusText.style.color = "#4a90e2"; render(); } // 每一帧动画都重绘整个棋盘和现有棋子,确保落子持续显示 function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 1. 绘制网格 ctx.strokeStyle = '#634d31'; ctx.lineWidth = 1; for (let i = 0; i < GRID_SIZE; i++) { ctx.beginPath(); ctx.moveTo(cellSize, cellSize * (i + 1)); ctx.lineTo(cellSize * GRID_SIZE, cellSize * (i + 1)); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cellSize * (i + 1), cellSize); ctx.lineTo(cellSize * (i + 1), cellSize * GRID_SIZE); ctx.stroke(); } // 2. 绘制星位 const stars = [[3, 3], [11, 3], [7, 7], [3, 11], [11, 11]]; ctx.fillStyle = '#634d31'; stars.forEach(([x, y]) => { ctx.beginPath(); ctx.arc(cellSize * (x + 1), cellSize * (y + 1), 4, 0, Math.PI * 2); ctx.fill(); }); // 3. 稳固绘制所有棋子 for (let x = 0; x < GRID_SIZE; x++) { for (let y = 0; y < GRID_SIZE; y++) { if (board[x][y] !== 0) { drawPiece(x, y, board[x][y]); } } } // 4. 叠加最新的浮动光环 if (lastMove) { drawLastMoveHalo(lastMove.x, lastMove.y); } } function drawPiece(x, y, type) { const cx = cellSize * (x + 1); const cy = cellSize * (y + 1); const radius = cellSize * 0.43; ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); const gradient = ctx.createRadialGradient(cx - radius*0.15, cy - radius*0.15, radius * 0.1, cx, cy, radius); if (type === 1) { gradient.addColorStop(0, '#666'); gradient.addColorStop(1, '#000'); } else { gradient.addColorStop(0, '#fff'); gradient.addColorStop(0.8, '#ddd'); gradient.addColorStop(1, '#bbb'); } ctx.fillStyle = gradient; ctx.shadowBlur = 4; ctx.shadowColor = "rgba(0, 0, 0, 0.3)"; ctx.shadowOffsetX = 1; ctx.shadowOffsetY = 2; ctx.fill(); ctx.restore(); } function drawLastMoveHalo(x, y) { const cx = cellSize * (x + 1); const cy = cellSize * (y + 1); const baseRadius = cellSize * 0.43; const pulse = Math.sin(haloAngle) * 3; const haloRadius = baseRadius + 3 + pulse; const opacity = 0.5 - (pulse + 3) * 0.04; ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, haloRadius, 0, Math.PI * 2); ctx.strokeStyle = `rgba(74, 144, 226, ${Math.max(0.1, opacity)})`; ctx.lineWidth = 2; ctx.stroke(); ctx.restore(); } function animate() { haloAngle += 0.07; render(); requestAnimationFrame(animate); } canvas.addEventListener('click', function(e) { if (gameOver || isAiTurn) return; const rect = canvas.getBoundingClientRect(); const clientX = e.clientX - rect.left; const clientY = e.clientY - rect.top; const x = Math.round(clientX / cellSize) - 1; const y = Math.round(clientY / cellSize) - 1; if (x < 0 || x >= GRID_SIZE || y < 0 || y >= GRID_SIZE || board[x][y] !== 0) return; board[x][y] = 1; lastMove = { x, y }; render(); if (checkWin(x, y, 1)) { statusText.innerText = "恭喜,你赢了!🎉"; statusText.style.color = "#2ecc71"; gameOver = true; return; } isAiTurn = true; statusText.innerText = "AI 正在思考..."; statusText.style.color = "#e67e22"; setTimeout(aiMove, 300); }); function aiMove() { if (gameOver) return; let bestScore = -1; let bestPoints = []; for (let x = 0; x < GRID_SIZE; x++) { for (let y = 0; y < GRID_SIZE; y++) { if (board[x][y] === 0) { let aiScore = evaluatePoint(x, y, 2); let playerScore = evaluatePoint(x, y, 1); let totalScore = aiScore + playerScore * 0.9; if (totalScore > bestScore) { bestScore = totalScore; bestPoints = [{x, y}]; } else if (totalScore === bestScore) { bestPoints.push({x, y}); } } } } if (bestPoints.length === 0) { statusText.innerText = "平局!"; gameOver = true; return; } const move = bestPoints[Math.floor(Math.random() * bestPoints.length)]; board[move.x][move.y] = 2; lastMove = { x: move.x, y: move.y }; render(); if (checkWin(move.x, move.y, 2)) { statusText.innerText = "AI 赢了,再接再厉!"; statusText.style.color = "#e74c3c"; gameOver = true; return; } isAiTurn = false; statusText.innerText = "你是黑棋,请落子"; statusText.style.color = "#4a90e2"; } function evaluatePoint(x, y, type) { let score = 0; const directions = [[1,0], [0,1], [1,1], [1,-1]]; directions.forEach(([dx, dy]) => { let count = 1; let block1 = false; let block2 = false; let tx = x + dx, ty = y + dy; while(tx >= 0 && tx < GRID_SIZE && ty >= 0 && ty < GRID_SIZE) { if (board[tx][ty] === type) { count++; } else { if (board[tx][ty] !== 0) block1 = true; break; } tx += dx; ty += dy; } if (tx < 0 || tx >= GRID_SIZE || ty < 0 || ty >= GRID_SIZE) block1 = true; tx = x - dx; ty = y - dy; while(tx >= 0 && tx < GRID_SIZE && ty >= 0 && ty < GRID_SIZE) { if (board[tx][ty] === type) { count++; } else { if (board[tx][ty] !== 0) block2 = true; break; } tx -= dx; ty -= dy; } if (tx < 0 || tx >= GRID_SIZE || ty < 0 || ty >= GRID_SIZE) block2 = true; if (count >= 5) score += 100000; else if (count === 4) { if (!block1 && !block2) score += 10000; else if (!block1 || !block2) score += 1000; } else if (count === 3) { if (!block1 && !block2) score += 1000; else if (!block1 || !block2) score += 100; } else if (count === 2) { if (!block1 && !block2) score += 100; else if (!block1 || !block2) score += 10; } }); return score; } function checkWin(x, y, type) { const directions = [[1,0], [0,1], [1,1], [1,-1]]; for (let [dx, dy] of directions) { let count = 1; let tx = x + dx, ty = y + dy; while (tx >= 0 && tx < GRID_SIZE && ty >= 0 && ty < GRID_SIZE && board[tx][ty] === type) { count++; tx += dx; ty += dy; } tx = x - dx; ty = y - dy; while (tx >= 0 && tx < GRID_SIZE && ty >= 0 && ty < GRID_SIZE && board[tx][ty] === type) { count++; tx -= dx; ty -= dy; } if (count >= 5) return true; } return false; } window.addEventListener('resize', initCanvas); window.onload = () => { // 先初始化画布(计算 cellSize 等) initCanvas(); // 再重置游戏(初始化 board 并首次渲染) restartGame(); // 启动动画循环 animate(); }; </script> </body> </html> 改编自 @518 佬发的源码,想玩的佬友也可以试试哈 4 个帖子 - 4 位参与者 阅读完整话题
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Weather · 天气</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } :root { --glass-bg: rgba(255, 255, 255, 0.1); --glass-border: rgba(255, 255, 255, 0.18); --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); --text-1: rgba(255, 255, 255, 1); --text-2: rgba(255, 255, 255, 0.72); --text-3: rgba(255, 255, 255, 0.5); } html, body { height: 100%; } body { font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 50%, #EC4899 100%); background-attachment: fixed; min-height: 100vh; color: var(--text-1); overflow-x: hidden; position: relative; } /* ===== 动态背景光斑 ===== */ .bg { position: fixed; inset: 0; z-index: 0; overflow: hidden; pointer-events: none; } .orb { position: absolute; border-radius: 50%; filter: blur(80px); opacity: 0.55; animation: orbFloat 22s ease-in-out infinite; } .orb-1 { width: 520px; height: 520px; background: radial-gradient(circle, #fbbf24, transparent 70%); top: -160px; right: -120px; } .orb-2 { width: 460px; height: 460px; background: radial-gradient(circle, #ec4899, transparent 70%); bottom: -160px; left: -120px; animation-delay: -8s; } .orb-3 { width: 420px; height: 420px; background: radial-gradient(circle, #06b6d4, transparent 70%); top: 40%; left: 30%; animation-delay: -16s; } @keyframes orbFloat { 0%, 100% { transform: translate(0, 0) scale(1); } 33% { transform: translate(40px, -60px) scale(1.1); } 66% { transform: translate(-30px, 40px) scale(0.95); } } /* 颗粒纹理 */ .grain { position: fixed; inset: 0; z-index: 1; pointer-events: none; opacity: 0.04; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); } .app { position: relative; z-index: 2; max-width: 1280px; margin: 0 auto; padding: 32px 24px; } /* ===== 顶部 ===== */ .header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 26px; flex-wrap: wrap; gap: 16px; } .logo { display: flex; align-items: center; gap: 12px; font-size: 20px; font-weight: 600; letter-spacing: -0.02em; } .logo-mark { width: 38px; height: 38px; border-radius: 11px; background: linear-gradient(135deg, #60a5fa, #a78bfa); display: grid; place-items: center; box-shadow: 0 6px 20px rgba(96, 165, 250, 0.4); } .logo-mark svg { width: 20px; height: 20px; } .city-tabs { display: flex; gap: 4px; padding: 5px; background: rgba(255, 255, 255, 0.08); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); border: 1px solid rgba(255, 255, 255, 0.14); border-radius: 100px; } .tab { padding: 8px 18px; background: transparent; border: none; color: var(--text-2); font-size: 13.5px; font-weight: 500; cursor: pointer; border-radius: 100px; transition: all 0.3s ease; font-family: inherit; white-space: nowrap; letter-spacing: 0.02em; } .tab:hover { color: var(--text-1); } .tab.active { background: rgba(255, 255, 255, 0.2); color: var(--text-1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); } /* ===== 布局 ===== */ .main-grid { display: grid; grid-template-columns: 1.45fr 1fr; gap: 20px; } @media (max-width: 900px) { .main-grid { grid-template-columns: 1fr; } } /* ===== 玻璃卡片基础 ===== */ .glass { background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); border: 1px solid var(--glass-border); border-radius: 28px; box-shadow: var(--glass-shadow); } /* ===== 主卡片 ===== */ .hero { padding: 30px; position: relative; overflow: hidden; min-height: 560px; display: flex; flex-direction: column; transition: opacity 0.3s ease; } .hero-glow { position: absolute; top: -30%; right: -30%; width: 80%; height: 130%; filter: blur(60px); pointer-events: none; z-index: 0; transition: background 0.8s ease; } .hero-header { display: flex; align-items: flex-start; justify-content: space-between; position: relative; z-index: 2; } .location { display: flex; align-items: center; gap: 10px; } .location-pin { width: 16px; height: 16px; color: var(--text-2); flex-shrink: 0; } .location-info h2 { font-size: 28px; font-weight: 600; letter-spacing: -0.02em; line-height: 1.1; } .location-sub { font-size: 13px; color: var(--text-2); margin-top: 4px; letter-spacing: 0.01em; } .time-block { text-align: right; } .time-now { font-size: 36px; font-weight: 200; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; line-height: 1; } .time-label { font-size: 11px; color: var(--text-2); margin-top: 6px; letter-spacing: 0.1em; text-transform: uppercase; } .hero-main { flex: 1; display: flex; align-items: center; justify-content: center; gap: 36px; margin: 14px 0 20px; position: relative; z-index: 2; } .weather-icon { width: 200px; height: 200px; filter: drop-shadow(0 20px 40px rgba(0, 0, 0, 0.18)); } .weather-icon svg { width: 100%; height: 100%; } .temperature { display: flex; align-items: flex-start; position: relative; } .temp-value { font-size: 148px; font-weight: 200; line-height: 0.85; letter-spacing: -0.06em; font-variant-numeric: tabular-nums; } .temp-unit { font-size: 64px; font-weight: 200; margin-top: 14px; color: var(--text-2); line-height: 1; } .hero-info { text-align: center; margin-bottom: 22px; position: relative; z-index: 2; } .condition { font-size: 20px; font-weight: 500; margin-bottom: 6px; letter-spacing: -0.01em; } .hi-lo { font-size: 14px; color: var(--text-2); font-weight: 400; } .hi-lo span { margin: 0 6px; } .hi-lo .dot { color: var(--text-3); } .divider { height: 1px; background: rgba(255, 255, 255, 0.12); margin: 0 0 18px; position: relative; z-index: 2; } .hourly-label { font-size: 11px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.12em; font-weight: 600; margin-bottom: 10px; position: relative; z-index: 2; } .hourly { display: flex; gap: 4px; overflow-x: auto; padding-bottom: 4px; position: relative; z-index: 2; } .hourly::-webkit-scrollbar { height: 0; display: none; } .hour { flex: 1; min-width: 58px; display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 10px 4px; border-radius: 16px; transition: background 0.2s; border: 1px solid transparent; } .hour.now { background: rgba(255, 255, 255, 0.14); border-color: rgba(255, 255, 255, 0.1); } .hour:hover { background: rgba(255, 255, 255, 0.08); } .hour-time { font-size: 12px; color: var(--text-2); font-weight: 500; } .hour.now .hour-time { color: var(--text-1); font-weight: 600; } .hour-icon { width: 32px; height: 32px; } .hour-icon svg { width: 100%; height: 100%; } .hour-temp { font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; } .hour-rain { font-size: 10px; color: #93c5fd; font-weight: 500; min-height: 12px; display: flex; align-items: center; gap: 2px; } .hour-rain svg { width: 9px; height: 9px; } /* ===== 侧边 ===== */ .side { display: flex; flex-direction: column; gap: 20px; } .stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } .stat { padding: 18px; border-radius: 20px; background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow); transition: all 0.3s; } .stat:hover { background: rgba(255, 255, 255, 0.14); transform: translateY(-2px); } .stat-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } .stat-label { font-size: 11px; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.12em; font-weight: 600; } .stat-icon { width: 18px; height: 18px; color: rgba(255, 255, 255, 0.7); } .stat-value { font-size: 28px; font-weight: 300; letter-spacing: -0.02em; line-height: 1; font-variant-numeric: tabular-nums; } .stat-value .unit { font-size: 15px; color: var(--text-2); margin-left: 2px; } .stat-sub { font-size: 11px; color: var(--text-3); margin-top: 6px; } .bar { margin-top: 10px; height: 4px; background: rgba(255, 255, 255, 0.1); border-radius: 2px; overflow: hidden; } .bar-fill { height: 100%; border-radius: 2px; transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1); } /* 风罗盘 */ .wind-info { display: flex; align-items: center; gap: 12px; margin-top: 6px; } .wind-compass { width: 44px; height: 44px; flex-shrink: 0; } .wind-compass svg { width: 100%; height: 100%; } .wind-detail { flex: 1; } .wind-speed { font-size: 24px; font-weight: 300; letter-spacing: -0.02em; line-height: 1; font-variant-numeric: tabular-nums; } .wind-dir { font-size: 11px; color: var(--text-3); margin-top: 4px; letter-spacing: 0.02em; } /* ===== 7 天预报 ===== */ .forecast { padding: 22px; flex: 1; background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); border: 1px solid var(--glass-border); border-radius: 24px; box-shadow: var(--glass-shadow); } .forecast-title { font-size: 11px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.12em; margin-bottom: 14px; } .forecast-list { display: flex; flex-direction: column; gap: 2px; } .forecast-item { display: grid; grid-template-columns: 50px 30px 1fr 72px; align-items: center; gap: 10px; padding: 9px 6px; border-radius: 12px; transition: background 0.2s; } .forecast-item:hover { background: rgba(255, 255, 255, 0.05); } .forecast-item.today { background: rgba(255, 255, 255, 0.07); } .forecast-day { font-size: 14px; font-weight: 500; } .forecast-day.today { font-weight: 600; } .forecast-icon { width: 28px; height: 28px; } .forecast-icon svg { width: 100%; height: 100%; } .forecast-bar { height: 4px; background: rgba(255, 255, 255, 0.08); border-radius: 2px; position: relative; overflow: hidden; } .forecast-bar-fill { position: absolute; height: 100%; border-radius: 2px; background: linear-gradient(90deg, #60a5fa, #fbbf24, #f87171); } .forecast-temp { font-size: 14px; font-weight: 500; text-align: right; font-variant-numeric: tabular-nums; display: flex; justify-content: flex-end; gap: 8px; } .forecast-temp .low { color: var(--text-3); } /* ===== 动画 ===== */ @keyframes rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes float-slow { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-6px); } } @keyframes rain-fall { 0% { transform: translateY(-12px); opacity: 0; } 20% { opacity: 1; } 100% { transform: translateY(14px); opacity: 0; } } @keyframes twinkle { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } } @keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } .sun-rays { animation: rotate 40s linear infinite; transform-origin: 100px 100px; } .float-slow { animation: float-slow 4s ease-in-out infinite; transform-origin: center; } .rain-drop { animation: rain-fall 1.2s ease-in infinite; } .rain-drop:nth-child(2) { animation-delay: -0.2s; } .rain-drop:nth-child(3) { animation-delay: -0.4s; } .rain-drop:nth-child(4) { animation-delay: -0.6s; } .rain-drop:nth-child(5) { animation-delay: -0.8s; } .rain-drop:nth-child(6) { animation-delay: -1.0s; } .twinkle { animation: twinkle 3s ease-in-out infinite; } .twinkle-2 { animation: twinkle 2.4s ease-in-out infinite; animation-delay: -1s; } /* ===== 响应式 ===== */ @media (max-width: 640px) { .app { padding: 20px 16px; } .header { flex-direction: column; align-items: stretch; } .city-tabs { justify-content: space-between; } .hero { padding: 22px; min-height: auto; } .hero-main { flex-direction: column; gap: 6px; margin: 10px 0 16px; } .temp-value { font-size: 100px; } .temp-unit { font-size: 44px; margin-top: 8px; } .weather-icon { width: 140px; height: 140px; } .location-info h2 { font-size: 22px; } .time-now { font-size: 28px; } .hero-header { flex-direction: column; gap: 10px; } .time-block { text-align: left; } .stat-value { font-size: 24px; } .stat { padding: 14px; } .forecast { padding: 18px; } .forecast-item { grid-template-columns: 42px 26px 1fr 64px; gap: 8px; padding: 8px 4px; } } </style> </head> <body> <div class="bg"> <div class="orb orb-1"></div> <div class="orb orb-2"></div> <div class="orb orb-3"></div> </div> <div class="grain"></div> <div class="app"> <header class="header"> <div class="logo"> <div class="logo-mark"> <svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> <circle cx="12" cy="12" r="4"/> <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/> </svg> </div> <span>Weather</span> </div> <nav class="city-tabs" id="cityTabs"> <button class="tab active" data-city="tokyo">东京</button> <button class="tab" data-city="paris">巴黎</button> <button class="tab" data-city="newyork">纽约</button> <button class="tab" data-city="sydney">悉尼</button> </nav> </header> <main class="main-grid"> <section class="hero glass" id="hero"></section> <aside class="side"> <div class="stats-grid" id="statsGrid"></div> <div class="forecast" id="forecast"></div> </aside> </main> </div> <script> /* ============ 城市数据 ============ */ const cityData = { tokyo: { name: '东京', country: '日本', timezone: 'Asia/Tokyo', dateStr: '星期二, 3月15日', temp: 24, condition: '晴朗', conditionKey: 'sunny', hi: 28, lo: 18, feels: 26, humidity: 68, wind: 12, windDir: '东北', windDeg: 45, uv: 5, uvLabel: '中等', visibility: 16, sunrise: '05:42', sunset: '17:58', hourly: [ { time: '现在', icon: 'sunny', temp: 24, rain: 0 }, { time: '14时', icon: 'sunny', temp: 25, rain: 0 }, { time: '15时', icon: 'partly-cloudy', temp: 26, rain: 0 }, { time: '16时', icon: 'partly-cloudy', temp: 25, rain: 10 }, { time: '17时', icon: 'cloudy', temp: 24, rain: 20 }, { time: '18时', icon: 'cloudy', temp: 22, rain: 20 }, { time: '19时', icon: 'partly-cloudy', temp: 20, rain: 10 } ], forecast: [ { day: '今天', icon: 'sunny', hi: 28, lo: 18, today: true }, { day: '周三', icon: 'partly-cloudy', hi: 25, lo: 16 }, { day: '周四', icon: 'rainy', hi: 20, lo: 14 }, { day: '周五', icon: 'partly-cloudy', hi: 22, lo: 15 }, { day: '周六', icon: 'sunny', hi: 26, lo: 17 }, { day: '周日', icon: 'sunny', hi: 28, lo: 19 }, { day: '周一', icon: 'partly-cloudy', hi: 24, lo: 16 } ] }, paris: { name: '巴黎', country: '法国', timezone: 'Europe/Paris', dateStr: '星期二, 3月15日', temp: 14, condition: '多云', conditionKey: 'cloudy', hi: 17, lo: 9, feels: 12, humidity: 78, wind: 18, windDir: '西风', windDeg: 270, uv: 2, uvLabel: '低', visibility: 10, sunrise: '07:08', sunset: '18:54', hourly: [ { time: '现在', icon: 'cloudy', temp: 14, rain: 30 }, { time: '14时', icon: 'cloudy', temp: 14, rain: 30 }, { time: '15时', icon: 'cloudy', temp: 15, rain: 40 }, { time: '16时', icon: 'rainy', temp: 14, rain: 60 }, { time: '17时', icon: 'rainy', temp: 13, rain: 70 }, { time: '18时', icon: 'rainy', temp: 12, rain: 60 }, { time: '19时', icon: 'cloudy', temp: 11, rain: 40 } ], forecast: [ { day: '今天', icon: 'cloudy', hi: 17, lo: 9, today: true }, { day: '周三', icon: 'rainy', hi: 12, lo: 7 }, { day: '周四', icon: 'rainy', hi: 11, lo: 6 }, { day: '周五', icon: 'cloudy', hi: 13, lo: 7 }, { day: '周六', icon: 'partly-cloudy', hi: 15, lo: 8 }, { day: '周日', icon: 'sunny', hi: 17, lo: 9 }, { day: '周一', icon: 'partly-cloudy', hi: 16, lo: 8 } ] }, newyork: { name: '纽约', country: '美国', timezone: 'America/New_York', dateStr: '星期二, 3月15日', temp: 8, condition: '雷阵雨', conditionKey: 'thunder', hi: 11, lo: 4, feels: 5, humidity: 85, wind: 22, windDir: '北风', windDeg: 0, uv: 1, uvLabel: '低', visibility: 6, sunrise: '06:54', sunset: '19:02', hourly: [ { time: '现在', icon: 'thunder', temp: 8, rain: 80 }, { time: '14时', icon: 'rainy', temp: 9, rain: 90 }, { time: '15时', icon: 'thunder', temp: 9, rain: 90 }, { time: '16时', icon: 'rainy', temp: 8, rain: 80 }, { time: '17时', icon: 'rainy', temp: 7, rain: 70 }, { time: '18时', icon: 'cloudy', temp: 6, rain: 50 }, { time: '19时', icon: 'cloudy', temp: 5, rain: 40 } ], forecast: [ { day: '今天', icon: 'thunder', hi: 11, lo: 4, today: true }, { day: '周三', icon: 'rainy', hi: 9, lo: 3 }, { day: '周四', icon: 'cloudy', hi: 10, lo: 4 }, { day: '周五', icon: 'partly-cloudy', hi: 12, lo: 5 }, { day: '周六', icon: 'sunny', hi: 14, lo: 6 }, { day: '周日', icon: 'partly-cloudy', hi: 13, lo: 7 }, { day: '周一', icon: 'rainy', hi: 11, lo: 6 } ] }, sydney: { name: '悉尼', country: '澳大利亚', timezone: 'Australia/Sydney', dateStr: '星期二, 3月15日', temp: 19, condition: '夜晚晴朗', conditionKey: 'clear-night', hi: 23, lo: 15, feels: 18, humidity: 62, wind: 15, windDir: '东南', windDeg: 135, uv: 0, uvLabel: '无', visibility: 20, sunrise: '06:48', sunset: '19:12', hourly: [ { time: '现在', icon: 'clear-night', temp: 19, rain: 0 }, { time: '22时', icon: 'clear-night', temp: 18, rain: 0 }, { time: '23时', icon: 'clear-night', temp: 17, rain: 0 }, { time: '00时', icon: 'partly-night', temp: 16, rain: 0 }, { time: '01时', icon: 'partly-night', temp: 16, rain: 0 }, { time: '02时', icon: 'partly-night', temp: 15, rain: 0 }, { time: '03时', icon: 'partly-night', temp: 15, rain: 0 } ], forecast: [ { day: '今天', icon: 'clear-night', hi: 23, lo: 15, today: true }, { day: '周三', icon: 'sunny', hi: 25, lo: 16 }, { day: '周四', icon: 'sunny', hi: 27, lo: 17 }, { day: '周五', icon: 'partly-cloudy', hi: 24, lo: 17 }, { day: '周六', icon: 'cloudy', hi: 21, lo: 16 }, { day: '周日', icon: 'rainy', hi: 19, lo: 14 }, { day: '周一', icon: 'partly-cloudy', hi: 22, lo: 15 } ] } }; /* ============ 通用图标 SVG ============ */ const ICONS_SVG = { location: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>`, humidity: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/></svg>`, wind: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.59 4.59A2 2 0 1 1 11 8H2m10.59 11.41A2 2 0 1 0 14 16H2m15.73-8.27A2.5 2.5 0 1 1 19.5 12H2"/></svg>`, uv: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>`, visibility: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`, rain: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="16" y1="13" x2="16" y2="20"/><line x1="8" y1="13" x2="8" y2="20"/><line x1="12" y1="15" x2="12" y2="22"/></svg>` }; /* ============ 天气图标生成器 ============ */ let _ic = 0; function icon(name) { const i = ++_ic; switch (name) { case 'sunny': return `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"> <defs> <radialGradient id="sg${i}"><stop offset="0%" stop-color="#FFF3B0" stop-opacity="0.55"/><stop offset="100%" stop-color="#FFD93D" stop-opacity="0"/></radialGradient> <radialGradient id="sc${i}"><stop offset="0%" stop-color="#FFFCEB"/><stop offset="60%" stop-color="#FFD93D"/><stop offset="100%" stop-color="#FF9A3C"/></radialGradient> </defs> <circle cx="100" cy="100" r="80" fill="url(#sg${i})"/> <g class="sun-rays"> <line x1="100" y1="20" x2="100" y2="42" stroke="#FFD93D" stroke-width="5" stroke-linecap="round"/> <line x1="100" y1="158" x2="100" y2="180" stroke="#FFD93D" stroke-width="5" stroke-linecap="round"/> <line x1="20" y1="100" x2="42" y2="100" stroke="#FFD93D" stroke-width="5" stroke-linecap="round"/> <line x1="158" y1="100" x2="180" y2="100" stroke="#FFD93D" stroke-width="5" stroke-linecap="round"/> <line x1="43" y1="43" x2="58" y2="58" stroke="#FFD93D" stroke-width="5" stroke-linecap="round"/> <line x1="142" y1="142" x2="157" y2="157" stroke="#FFD93D" stroke-width="5" stroke-linecap="round"/> <line x1="157" y1="43" x2="142" y2="58" stroke="#FFD93D" stroke-width="5" stroke-linecap="round"/> <line x1="58" y1="142" x2="43" y2="157" stroke="#FFD93D" stroke-width="5" stroke-linecap="round"/> </g> <circle cx="100" cy="100" r="38" fill="url(#sc${i})"/> </svg>`; case 'partly-cloudy': return `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"> <defs> <radialGradient id="pc${i}"><stop offset="0%" stop-color="#FFFCEB"/><stop offset="100%" stop-color="#FFD93D"/></radialGradient> <linearGradient id="cl${i}" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="#FFFFFF"/><stop offset="100%" stop-color="#D6DEE9"/></linearGradient> </defs> <g class="float-slow"> <circle cx="70" cy="70" r="28" fill="url(#pc${i})"/> <g class="sun-rays"> <line x1="70" y1="22" x2="70" y2="32" stroke="#FFD93D" stroke-width="3" stroke-linecap="round"/> <line x1="70" y1="108" x2="70" y2="118" stroke="#FFD93D" stroke-width="3" stroke-linecap="round"/> <line x1="22" y1="70" x2="32" y2="70" stroke="#FFD93D" stroke-width="3" stroke-linecap="round"/> <line x1="108" y1="70" x2="118" y2="70" stroke="#FFD93D" stroke-width="3" stroke-linecap="round"/> <line x1="37" y1="37" x2="44" y2="44" stroke="#FFD93D" stroke-width="3" stroke-linecap="round"/> <line x1="96" y1="96" x2="103" y2="103" stroke="#FFD93D" stroke-width="3" stroke-linecap="round"/> <line x1="103" y1="37" x2="96" y2="44" stroke="#FFD93D" stroke-width="3" stroke-linecap="round"/> <line x1="44" y1="96" x2="37" y2="103" stroke="#FFD93D" stroke-width="3" stroke-linecap="round"/> </g> </g> <path d="M 75 150 Q 42 150 42 122 Q 42 96 70 96 Q 76 74 102 74 Q 132 74 138 102 Q 168 102 168 125 Q 168 150 145 150 Z" fill="url(#cl${i})"/> </svg>`; case 'cloudy': return `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"> <defs> <linearGradient id="cc${i}" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="#FFFFFF"/><stop offset="100%" stop-color="#C7D2E0"/></linearGradient> </defs> <g class="float-slow"> <path d="M 60 125 Q 28 125 28 100 Q 28 73 55 73 Q 60 46 92 46 Q 122 46 132 73 Q 168 73 168 105 Q 168 125 142 125 Z" fill="url(#cc${i})"/> <path d="M 50 160 Q 22 160 22 142 Q 22 125 45 125 Q 50 110 72 110 Q 96 110 102 128 Q 126 128 126 146 Q 126 160 108 160 Z" fill="url(#cc${i})" opacity="0.78"/> </g> </svg>`; case 'rainy': return `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"> <defs> <linearGradient id="rc${i}" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="#E2E8F0"/><stop offset="100%" stop-color="#94A3B8"/></linearGradient> </defs> <g class="float-slow"> <path d="M 60 105 Q 28 105 28 80 Q 28 53 55 53 Q 60 28 92 28 Q 122 28 132 53 Q 168 53 168 85 Q 168 105 142 105 Z" fill="url(#rc${i})"/> </g> <line x1="55" y1="125" x2="50" y2="155" stroke="#60A5FA" stroke-width="4" stroke-linecap="round" class="rain-drop"/> <line x1="80" y1="130" x2="75" y2="160" stroke="#60A5FA" stroke-width="4" stroke-linecap="round" class="rain-drop"/> <line x1="105" y1="125" x2="100" y2="155" stroke="#60A5FA" stroke-width="4" stroke-linecap="round" class="rain-drop"/> <line x1="130" y1="130" x2="125" y2="160" stroke="#60A5FA" stroke-width="4" stroke-linecap="round" class="rain-drop"/> <line x1="65" y1="158" x2="60" y2="180" stroke="#60A5FA" stroke-width="3" stroke-linecap="round" opacity="0.5" class="rain-drop"/> <line x1="115" y1="158" x2="110" y2="180" stroke="#60A5FA" stroke-width="3" stroke-linecap="round" opacity="0.5" class="rain-drop"/> </svg>`; case 'thunder': return `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"> <defs> <linearGradient id="tc${i}" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="#CBD5E1"/><stop offset="100%" stop-color="#64748B"/></linearGradient> </defs> <g class="float-slow"> <path d="M 60 100 Q 28 100 28 75 Q 28 48 55 48 Q 60 23 92 23 Q 122 23 132 48 Q 168 48 168 80 Q 168 100 142 100 Z" fill="url(#tc${i})"/> </g> <path d="M 102 105 L 75 142 L 95 142 L 80 178 L 128 130 L 108 130 L 122 105 Z" fill="#FCD34D" stroke="#F59E0B" stroke-width="1.5" stroke-linejoin="round"/> </svg>`; case 'clear-night': return `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"> <defs> <radialGradient id="mc${i}"><stop offset="0%" stop-color="#FFFFFF"/><stop offset="60%" stop-color="#E0E7FF"/><stop offset="100%" stop-color="#A5B4FC"/></radialGradient> </defs> <circle cx="125" cy="100" r="55" fill="url(#mc${i})"/> <circle cx="105" cy="90" r="50" fill="#4F46E5" opacity="0.4"/> <circle cx="55" cy="50" r="3" fill="#FFFFFF" class="twinkle"/> <circle cx="165" cy="55" r="2.5" fill="#FFFFFF" class="twinkle-2"/> <circle cx="50" cy="158" r="3" fill="#FFFFFF" class="twinkle-2"/> <circle cx="170" cy="160" r="2" fill="#FFFFFF" class="twinkle"/> <circle cx="35" cy="105" r="2" fill="#FFFFFF" class="twinkle"/> <circle cx="180" cy="105" r="2" fill="#FFFFFF" class="twinkle-2"/> </svg>`; case 'partly-night': return `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"> <defs> <radialGradient id="mc2${i}"><stop offset="0%" stop-color="#FFFFFF"/><stop offset="100%" stop-color="#A5B4FC"/></radialGradient> <linearGradient id="cl2${i}" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="#FFFFFF"/><stop offset="100%" stop-color="#C7D2E0"/></linearGradient> </defs> <circle cx="80" cy="65" r="32" fill="url(#mc2${i})"/> <circle cx="65" cy="58" r="28" fill="#4F46E5" opacity="0.5"/> <circle cx="155" cy="50" r="2" fill="#FFFFFF" class="twinkle"/> <circle cx="40" cy="105" r="2" fill="#FFFFFF" class="twinkle-2"/> <circle cx="170" cy="130" r="2" fill="#FFFFFF" class="twinkle"/> <g class="float-slow"> <path d="M 65 150 Q 38 150 38 128 Q 38 106 60 106 Q 65 90 88 90 Q 113 90 118 112 Q 142 112 142 132 Q 142 150 122 150 Z" fill="url(#cl2${i})"/> </g> </svg>`; default: return ''; } } /* ============ 工具 ============ */ function glowFor(key) { return { 'sunny': 'radial-gradient(circle, rgba(255, 217, 61, 0.32) 0%, transparent 60%)', 'partly-cloudy': 'radial-gradient(circle, rgba(255, 200, 100, 0.28) 0%, transparent 60%)', 'cloudy': 'radial-gradient(circle, rgba(200, 215, 230, 0.22) 0%, transparent 60%)', 'rainy': 'radial-gradient(circle, rgba(96, 165, 250, 0.28) 0%, transparent 60%)', 'thunder': 'radial-gradient(circle, rgba(252, 211, 77, 0.25) 0%, transparent 60%)', 'clear-night': 'radial-gradient(circle, rgba(165, 180, 252, 0.28) 0%, transparent 60%)', 'partly-night': 'radial-gradient(circle, rgba(165, 180, 252, 0.22) 0%, transparent 60%)' }[key] || 'radial-gradient(circle, rgba(255, 217, 61, 0.3) 0%, transparent 60%)'; } function uvBar(uv) { if (uv <= 2) return 'linear-gradient(90deg, #4ade80, #facc15)'; if (uv <= 5) return 'linear-gradient(90deg, #facc15, #fb923c)'; if (uv <= 7) return 'linear-gradient(90deg, #fb923c, #f87171)'; return 'linear-gradient(90deg, #f87171, #a855f7)'; } /* ============ 渲染 ============ */ function renderHero(city) { const d = cityData[city]; const el = document.getElementById('hero'); el.style.opacity = '0'; setTimeout(() => { el.innerHTML = ` <div class="hero-glow" style="background:${glowFor(d.conditionKey)};"></div> <div class="hero-header"> <div class="location"> <div class="location-pin">${ICONS_SVG.location}</div> <div class="location-info"> <h2>${d.name}</h2> <div class="location-sub">${d.country} · ${d.dateStr}</div> </div> </div> <div class="time-block"> <div class="time-now" id="timeNow">--:--</div> <div class="time-label">当地时间</div> </div> </div> <div class="hero-main"> <div class="weather-icon">${icon(d.conditionKey)}</div> <div class="temperature"> <span class="temp-value">${d.temp}</span> <span class="temp-unit">°</span> </div> </div> <div class="hero-info"> <div class="condition">${d.condition}</div> <div class="hi-lo">最高 <span>${d.hi}°</span><span class="dot">·</span>最低 <span>${d.lo}°</span><span class="dot">·</span>体感 ${d.feels}°</div> </div> <div class="divider"></div> <div class="hourly-label">小时预报</div> <div class="hourly"> ${d.hourly.map((h, idx) => ` <div class="hour ${idx === 0 ? 'now' : ''}"> <div class="hour-time">${h.time}</div> <div class="hour-icon">${icon(h.icon)}</div> <div class="hour-temp">${h.temp}°</div> <div class="hour-rain">${h.rain > 0 ? `${ICONS_SVG.rain}${h.rain}%` : ''}</div> </div> `).join('')} </div> `; el.style.opacity = '1'; updateClock(); }, 180); } function renderStats(city) { const d = cityData[city]; document.getElementById('statsGrid').innerHTML = ` <div class="stat"> <div class="stat-header"> <div class="stat-label">湿度</div> <div class="stat-icon">${ICONS_SVG.humidity}</div> </div> <div class="stat-value">${d.humidity}<span class="unit">%</span></div> <div class="stat-sub">${d.humidity > 70 ? '较为潮湿' : d.humidity > 40 ? '舒适宜人' : '较为干燥'}</div> <div class="bar"><div class="bar-fill" style="width:${d.humidity}%; background:linear-gradient(90deg, #60a5fa, #a78bfa);"></div></div> </div> <div class="stat"> <div class="stat-header"> <div class="stat-label">风速</div> <div class="stat-icon">${ICONS_SVG.wind}</div> </div> <div class="wind-info"> <div class="wind-compass"> <svg viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"> <circle cx="25" cy="25" r="20" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="1"/> <circle cx="25" cy="25" r="14" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="0.5" stroke-dasharray="2,2"/> <text x="25" y="7" text-anchor="middle" font-size="5" fill="rgba(255,255,255,0.45)" font-family="sans-serif" font-weight="600">N</text> <text x="25" y="48" text-anchor="middle" font-size="5" fill="rgba(255,255,255,0.3)" font-family="sans-serif">S</text> <text x="4" y="28" text-anchor="middle" font-size="5" fill="rgba(255,255,255,0.3)" font-family="sans-serif">W</text> <text x="46" y="28" text-anchor="middle" font-size="5" fill="rgba(255,255,255,0.3)" font-family="sans-serif">E</text> <g transform="rotate(${d.windDeg} 25 25)"> <path d="M 25 11 L 21.5 27 L 25 24 L 28.5 27 Z" fill="#60A5FA"/> <path d="M 25 39 L 22 28 L 28 28 Z" fill="rgba(255,255,255,0.25)"/> <circle cx="25" cy="25" r="2" fill="rgba(96,165,250,0.4)"/> </g> </svg> </div> <div class="wind-detail"> <div class="wind-speed">${d.wind}<span class="unit"> km/h</span></div> <div class="wind-dir">${d.windDir} · ${d.wind > 20 ? '强风' : d.wind > 10 ? '和风' : '微风'}</div> </div> </div> </div> <div class="stat"> <div class="stat-header"> <div class="stat-label">紫外线</div> <div class="stat-icon">${ICONS_SVG.uv}</div> </div> <div class="stat-value">${d.uv}</div> <div class="stat-sub">${d.uvLabel}${d.uv >= 6 ? ' · 注意防晒' : ''}</div> <div class="bar"><div class="bar-fill" style="width:${Math.max((d.uv / 11) * 100, 4)}%; background:${uvBar(d.uv)};"></div></div> </div> <div class="stat"> <div class="stat-header"> <div class="stat-label">能见度</div> <div class="stat-icon">${ICONS_SVG.visibility}</div> </div> <div class="stat-value">${d.visibility}<span class="unit"> km</span></div> <div class="stat-sub">${d.visibility > 15 ? '极佳视野' : d.visibility > 10 ? '视野良好' : d.visibility > 5 ? '视野一般' : '视野较差'}</div> </div> `; } function renderForecast(city) { const d = cityData[city]; const allHi = Math.max(...d.forecast.map(f => f.hi)); const allLo = Math.min(...d.forecast.map(f => f.lo)); const range = Math.max(allHi - allLo, 1); document.getElementById('forecast').innerHTML = ` <div class="forecast-title">7 天预报</div> <div class="forecast-list"> ${d.forecast.map(f => { const left = ((f.lo - allLo) / range) * 100; const width = ((f.hi - f.lo) / range) * 100; return ` <div class="forecast-item ${f.today ? 'today' : ''}"> <div class="forecast-day ${f.today ? 'today' : ''}">${f.day}</div> <div class="forecast-icon">${icon(f.icon)}</div> <div class="forecast-bar"> <div class="forecast-bar-fill" style="left:${left}%; width:${Math.max(width, 6)}%;"></div> </div> <div class="forecast-temp"> <span class="low">${f.lo}°</span> <span>${f.hi}°</span> </div> </div> `; }).join('')} </div> `; } function render(city) { renderHero(city); renderStats(city); renderForecast(city); } function updateClock() { const el = document.getElementById('timeNow'); if (!el) return; try { el.textContent = new Date().toLocaleTimeString('zh-CN', { timeZone: cityData[currentCity].timezone, hour: '2-digit', minute: '2-digit', hour12: false }); } catch (e) {} } /* ============ 初始化 ============ */ let currentCity = 'tokyo'; document.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', () => { if (tab.classList.contains('active')) return; document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); currentCity = tab.dataset.city; render(currentCity); }); }); render('tokyo'); setInterval(updateClock, 1000); </script> </body> </html> 8 个帖子 - 7 位参与者 阅读完整话题
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>MIMO API Key Extractor</title> <style> :root { --system-blue: #007AFF; --system-blue-hover: #0062cc; --system-gray: #8E8E93; --system-gray-6: #F2F2F7; --system-background: #F5F5F7; --card-background: rgba(255, 255, 255, 0.85); --glass-border: rgba(255, 255, 255, 0.6); --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.04); --shadow-md: 0 12px 24px rgba(0, 0, 0, 0.08); --radius-lg: 20px; --radius-md: 12px; --font-primary: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; --font-mono: "SF Mono", "Menlo", "Monaco", "Courier New", monospace; } * { margin: 0; padding: 0; box-sizing: border-box; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } body { font-family: var(--font-primary); background-color: var(--system-background); background-image: radial-gradient(at 80% 0%, hsla(189,100%,56%,0.1) 0px, transparent 50%), radial-gradient(at 0% 50%, hsla(355,100%,93%,0.3) 0px, transparent 50%), radial-gradient(at 80% 50%, hsla(240,100%,70%,0.05) 0px, transparent 50%); color: #1d1d1f; min-height: 100vh; display: flex; justify-content: center; align-items: center; padding: 40px 20px; } .main-container { width: 100%; max-width: 860px; background: var(--card-background); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-md); overflow: hidden; animation: fadeIn 0.6s cubic-bezier(0.16, 1, 0.3, 1); } /* 头部区域 */ .header { padding: 40px 40px 20px; text-align: center; } .header h1 { font-size: 32px; font-weight: 700; letter-spacing: -0.5px; margin-bottom: 8px; background: linear-gradient(135deg, #1d1d1f 0%, #434344 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } .header p { color: var(--system-gray); font-size: 15px; font-weight: 400; } /* 内容区域 */ .content { padding: 20px 40px 40px; } /* 输入框区域 */ .input-wrapper { position: relative; margin-bottom: 24px; } textarea { width: 100%; min-height: 180px; padding: 20px; border: none; border-radius: var(--radius-md); background: var(--system-gray-6); font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: #1d1d1f; resize: vertical; transition: all 0.3s cubic-bezier(0.25, 0.1, 0.25, 1); box-shadow: inset 0 1px 4px rgba(0,0,0,0.02); } textarea::placeholder { color: #aeaeb2; font-family: var(--font-primary); } textarea:focus { outline: none; background: #fff; box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.15); } /* 按钮组 */ .action-bar { display: flex; gap: 16px; margin-bottom: 32px; } button { border: none; padding: 14px 28px; border-radius: 980px; /* Pill shape */ font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.2s cubic-bezier(0.25, 0.1, 0.25, 1); display: flex; align-items: center; justify-content: center; gap: 8px; } button:active { transform: scale(0.96); } .btn-primary { background: var(--system-blue); color: white; flex: 2; box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3); } .btn-primary:hover { background: var(--system-blue-hover); box-shadow: 0 6px 16px rgba(0, 122, 255, 0.4); } .btn-secondary { background: rgba(0, 0, 0, 0.05); color: #1d1d1f; flex: 1; } .btn-secondary:hover { background: rgba(0, 0, 0, 0.1); } /* 结果区域 */ .result-panel { border-top: 1px solid rgba(0,0,0,0.06); padding-top: 30px; display: none; /* 默认隐藏 */ animation: slideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1); } .result-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } .result-title { font-size: 17px; font-weight: 600; color: #1d1d1f; } .badges { display: flex; gap: 8px; } .badge { padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 500; letter-spacing: 0.3px; } .badge-count { background: rgba(0, 122, 255, 0.1); color: var(--system-blue); } .badge-check { background: rgba(52, 199, 89, 0.1); color: #34C759; } .key-list { max-height: 360px; overflow-y: auto; background: #fff; border-radius: var(--radius-md); border: 1px solid rgba(0,0,0,0.04); box-shadow: 0 4px 12px rgba(0,0,0,0.02); } /* 自定义滚动条 */ .key-list::-webkit-scrollbar { width: 8px; } .key-list::-webkit-scrollbar-track { background: transparent; } .key-list::-webkit-scrollbar-thumb { background-color: rgba(0,0,0,0.1); border-radius: 4px; } .key-item { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid rgba(0,0,0,0.04); transition: background 0.2s; } .key-item:last-child { border-bottom: none; } .key-item:hover { background: #f9f9fa; } .key-text { font-family: var(--font-mono); font-size: 13px; color: #333; word-break: break-all; margin-right: 16px; } .btn-copy-small { padding: 6px 12px; font-size: 12px; border-radius: 6px; background: transparent; color: var(--system-blue); border: 1px solid rgba(0, 122, 255, 0.2); flex-shrink: 0; } .btn-copy-small:hover { background: rgba(0, 122, 255, 0.05); } .btn-copy-small.copied { background: #34C759; border-color: transparent; color: white; } /* 底部批量操作 */ .export-actions { margin-top: 24px; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .btn-export { background: white; border: 1px solid rgba(0,0,0,0.08); color: #1d1d1f; box-shadow: 0 2px 6px rgba(0,0,0,0.02); } .btn-export:hover { border-color: rgba(0,0,0,0.15); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.05); } /* 动画定义 */ @keyframes fadeIn { from { opacity: 0; transform: scale(0.98); } to { opacity: 1; transform: scale(1); } } @keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } /* 响应式 */ @media (max-width: 600px) { body { padding: 20px 10px; } .header { padding: 30px 20px 10px; } .content { padding: 20px; } .action-bar { flex-direction: column; } .export-actions { grid-template-columns: 1fr; } } </style> </head> <body> <div class="main-container"> <div class="header"> <h1>API Key Extraction</h1> <p>Professional MIMO Code Key Processor</p> </div> <div class="content"> <div class="input-wrapper"> <textarea id="inputText" placeholder="请在此粘贴包含 MIMO API 密钥的文本内容..."></textarea> </div> <div class="action-bar"> <button class="btn-primary" onclick="extractKeys()"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg> 智能提取 </button> <button class="btn-secondary" onclick="clearAll()"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg> 清空 </button> </div> <div id="resultSection" class="result-panel"> <div class="result-header"> <span class="result-title">提取结果</span> <div class="badges"> <span class="badge badge-count" id="countBadge">0 items</span> <span class="badge badge-check">Length: 51 chars</span> </div> </div> <div class="key-list" id="resultList"> <!-- Javascript will populate this --> </div> <div class="export-actions"> <button class="btn-export" onclick="copyAllKeys('newline')"> 📋 复制全部 (换行分隔) </button> <button class="btn-export" onclick="copyAllKeys('comma')"> 📑 复制全部 (逗号分隔) </button> </div> </div> </div> </div> <script> let extractedKeys = []; function extractKeys() { const inputText = document.getElementById('inputText').value; // 交互反馈:如果没有输入,输入框抖动一下 if (!inputText.trim()) { const textarea = document.getElementById('inputText'); textarea.style.transform = 'translateX(4px)'; setTimeout(() => textarea.style.transform = 'translateX(-4px)', 50); setTimeout(() => textarea.style.transform = 'translateX(0)', 100); return; } // 核心正则逻辑:tp- 开头 + 恰好48位字母数字(大小写混合) const regex = /tp-[a-zA-Z0-9]{48}\b/g; const keys = inputText.match(regex); if (keys && keys.length > 0) { // 去重 extractedKeys = [...new Set(keys)]; // 严格校验长度:tp-(3) + 48 = 51 extractedKeys = extractedKeys.filter(key => key.length === 51); if (extractedKeys.length > 0) { displayResults(); } else { showToast('未找到符合标准长度(51字符)的密钥', 'error'); document.getElementById('resultSection').style.display = 'none'; } } else { showToast('未检测到有效的 tp- 格式密钥', 'error'); document.getElementById('resultSection').style.display = 'none'; } } function displayResults() { const resultSection = document.getElementById('resultSection'); const resultList = document.getElementById('resultList'); const countBadge = document.getElementById('countBadge'); // 显现动画 resultSection.style.display = 'block'; countBadge.textContent = `${extractedKeys.length} 个密钥`; resultList.innerHTML = ''; extractedKeys.forEach((key, index) => { const keyItem = document.createElement('div'); keyItem.className = 'key-item'; // 延迟动画让列表逐个出现 keyItem.style.animation = `fadeIn 0.3s ease backwards ${index * 0.05}s`; keyItem.innerHTML = ` <span class="key-text">${key}</span> <button class="btn-copy-small" onclick="copyKey('${key}', this)">复制</button> `; resultList.appendChild(keyItem); }); // 滚动到结果区 resultSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } function copyKey(key, btnElement) { navigator.clipboard.writeText(key).then(() => { const originalText = btnElement.textContent; btnElement.textContent = '已复制'; btnElement.classList.add('copied'); setTimeout(() => { btnElement.textContent = originalText; btnElement.classList.remove('copied'); }, 1500); }).catch(err => { showToast('复制失败', 'error'); }); } function copyAllKeys(format) { if (extractedKeys.length === 0) return; let content = ''; if (format === 'newline') { content = extractedKeys.join('\n'); } else if (format === 'comma') { content = extractedKeys.join(','); } navigator.clipboard.writeText(content).then(() => { const eventBtn = event.currentTarget; // 获取点击的按钮 const originalText = eventBtn.innerText; eventBtn.innerText = '✅ 复制成功'; eventBtn.style.color = '#34C759'; eventBtn.style.borderColor = '#34C759'; setTimeout(() => { eventBtn.innerText = originalText; eventBtn.style.color = ''; eventBtn.style.borderColor = ''; }, 2000); }).catch(err => { showToast('批量复制失败', 'error'); }); } function clearAll() { const textarea = document.getElementById('inputText'); const resultSection = document.getElementById('resultSection'); // 简单的淡出效果 textarea.value = ''; textarea.focus(); if (resultSection.style.display === 'block') { resultSection.style.opacity = '0'; setTimeout(() => { resultSection.style.display = 'none'; resultSection.style.opacity = '1'; extractedKeys = []; }, 300); } else { extractedKeys = []; } } function showToast(message, type) { alert(message); } </script> </body> </html> [!提示] 巧用L站搜索框 11 个帖子 - 9 位参与者 阅读完整话题
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>NAT64 转换器 - IPv4 到 IPv6</title> <style> :root { --bg: #f5f7fb; --card-bg: #ffffff; --text: #1e293b; --text-secondary: #475569; --border: #e2e8f0; --accent: #2563eb; --accent-hover: #1d4ed8; --success: #059669; --error: #dc2626; --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05); --radius: 12px; } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; background: linear-gradient(135deg, #f0f4ff 0%, #e8edf5 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1.5rem; color: var(--text); } .container { background: var(--card-bg); border-radius: var(--radius); box-shadow: var(--shadow), 0 10px 25px -5px rgba(0, 0, 0, 0.08); width: 100%; max-width: 700px; padding: 2.5rem; border: 1px solid var(--border); transition: all 0.2s ease; } h1 { font-size: 1.8rem; font-weight: 700; margin-bottom: 0.5rem; letter-spacing: -0.5px; display: flex; align-items: center; gap: 0.5rem; } h1 span { background: var(--accent); color: white; font-size: 0.9rem; padding: 0.2rem 0.8rem; border-radius: 20px; font-weight: 500; letter-spacing: 0; } .subtitle { color: var(--text-secondary); margin-bottom: 2rem; font-size: 0.95rem; border-left: 3px solid var(--accent); padding-left: 0.8rem; } .form-group { margin-bottom: 1.5rem; } label { display: block; font-weight: 600; font-size: 0.9rem; margin-bottom: 0.4rem; color: var(--text); } .input-wrapper { display: flex; align-items: center; gap: 0.5rem; background: #f8fafc; border: 1px solid var(--border); border-radius: 8px; padding: 0.5rem 0.8rem; transition: border-color 0.2s, box-shadow 0.2s; } .input-wrapper:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); } .input-wrapper input { border: none; background: transparent; flex: 1; font-size: 1rem; padding: 0.5rem 0; outline: none; font-family: 'JetBrains Mono', 'Fira Code', monospace; color: var(--text); } .input-wrapper .icon { color: var(--text-secondary); font-size: 1.1rem; } select { width: 100%; padding: 0.75rem 0.8rem; border: 1px solid var(--border); border-radius: 8px; background: #f8fafc; font-size: 0.95rem; font-family: 'JetBrains Mono', 'Fira Code', monospace; color: var(--text); outline: none; cursor: pointer; transition: border-color 0.2s, box-shadow 0.2s; appearance: none; background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="%23475569" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>'); background-repeat: no-repeat; background-position: right 0.8rem center; background-size: 1.2rem; } select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); } .custom-prefix { margin-top: 0.8rem; display: none; } .custom-prefix.show { display: block; } .result-box { background: #f1f5f9; border-radius: 8px; padding: 1.2rem; margin: 1.8rem 0 1rem; border: 1px solid var(--border); word-break: break-all; } .result-label { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin-bottom: 0.3rem; } .result-ipv6 { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 1.3rem; font-weight: 700; color: var(--accent); background: white; padding: 0.5rem 0.8rem; border-radius: 6px; display: inline-block; max-width: 100%; overflow-wrap: anywhere; border: 1px solid #cbd5e1; } .error-message { color: var(--error); font-size: 0.9rem; margin-top: 0.3rem; display: flex; align-items: center; gap: 0.3rem; } .conversion-detail { font-size: 0.85rem; color: var(--text-secondary); margin-top: 0.8rem; background: #f8fafc; border-radius: 6px; padding: 0.6rem 0.8rem; font-family: 'JetBrains Mono', monospace; } .footer-note { font-size: 0.8rem; color: #64748b; margin-top: 1.5rem; text-align: center; border-top: 1px solid var(--border); padding-top: 1rem; } @media (max-width: 500px) { .container { padding: 1.5rem; } h1 { font-size: 1.5rem; } } </style> </head> <body> <div class="container"> <h1> NAT64 转换器 <span>IPv4 → IPv6</span> </h1> <div class="subtitle"> 基于 nat64.xyz 公共 NAT64 前缀列表 · 实时合成地址 </div> <div class="form-group"> <label for="ipv4Input">IPv4 地址</label> <div class="input-wrapper"> <span class="icon">🌐</span> <input type="text" id="ipv4Input" placeholder="例如 104.21.88.129" value="104.21.88.129" autofocus> </div> </div> <div class="form-group"> <label for="prefixSelect">NAT64 前缀 (Provider / Location)</label> <select id="prefixSelect"> <optgroup label="Kasper Dupont"> <option value="2a00:1098:2b::/96">2a00:1098:2b::/96 – Germany (Nürnberg)</option> <option value="2a00:1098:2c:1::/96">2a00:1098:2c:1::/96 – Germany (Nürnberg)</option> <option value="2a01:4f8:c2c:123f:64::/96">2a01:4f8:c2c:123f:64::/96 – Germany (Nürnberg)</option> <option value="2a01:4f9:c010:3f02:64::/96">2a01:4f9:c010:3f02:64::/96 – Germany (Nürnberg)</option> </optgroup> <optgroup label="level66.services"> <option value="2001:67c:2960:6464::/96" selected>2001:67c:2960:6464::/96 – Anycast (Germany)</option> </optgroup> <optgroup label="Trex"> <option value="2001:67c:2b0:db32:0:1::/96">2001:67c:2b0:db32:0:1::/96 – Finland (Tampere)</option> </optgroup> <optgroup label="ZTVI"> <option value="2602:fc59:b0:64::/96">2602:fc59:b0:64::/96 – USA (Fremont)</option> <option value="2602:fc59:11:64::/96">2602:fc59:11:64::/96 – USA (Chicago)</option> </optgroup> <option value="custom">🔧 自定义前缀 (输入 /96 前缀)</option> </select> </div> <div class="custom-prefix" id="customPrefixWrapper"> <label for="customPrefixInput">自定义 NAT64 前缀 (/96)</label> <div class="input-wrapper"> <span class="icon">🔹</span> <input type="text" id="customPrefixInput" placeholder="例如 2001:db8:abcd:1234::/96"> </div> </div> <div class="result-box"> <div class="result-label">合成的 IPv6 地址</div> <div class="result-ipv6" id="resultIPv6">2001:67c:2960:6464::6815:5881</div> <div class="error-message" id="errorMessage"></div> <div class="conversion-detail" id="detailMapping"></div> </div> <div class="footer-note"> 数据来源 <strong>nat64.xyz</strong> · 十六进制嵌入 (RFC 6052) · 仅供学习与测试 </div> </div> <script> (function() { // DOM 元素 const ipv4Input = document.getElementById('ipv4Input'); const prefixSelect = document.getElementById('prefixSelect'); const customPrefixWrapper = document.getElementById('customPrefixWrapper'); const customPrefixInput = document.getElementById('customPrefixInput'); const resultIPv6 = document.getElementById('resultIPv6'); const errorMessage = document.getElementById('errorMessage'); const detailMapping = document.getElementById('detailMapping'); // 展开 IPv6 地址为 8 个 16-bit 块数组 function expandIPv6(addr) { // 移除可能的 zone ID (%) addr = addr.split('%')[0]; if (addr.includes('::')) { const parts = addr.split('::'); const left = parts[0] ? parts[0].split(':') : []; const right = parts[1] ? parts[1].split(':') : []; const missing = 8 - left.length - right.length; if (missing < 0) return null; // 无效地址 const middle = new Array(missing).fill('0'); const blocks = left.concat(middle, right); return blocks.map(b => parseInt(b || '0', 16)); } else { const blocks = addr.split(':'); if (blocks.length !== 8) return null; return blocks.map(b => parseInt(b || '0', 16)); } } // 压缩 IPv6 地址块数组为字符串 function compressIPv6(blocks) { if (blocks.length !== 8) return null; const strs = blocks.map(b => b.toString(16)); // 寻找最长连续零块 let bestStart = -1, bestLen = 0; let currStart = -1, currLen = 0; for (let i = 0; i < strs.length; i++) { if (strs[i] === '0') { if (currStart === -1) currStart = i; currLen++; } else { if (currLen > bestLen) { bestLen = currLen; bestStart = currStart; } currStart = -1; currLen = 0; } } if (currLen > bestLen) { bestLen = currLen; bestStart = currStart; } if (bestLen < 2) { return strs.join(':'); } const left = strs.slice(0, bestStart); const right = strs.slice(bestStart + bestLen); let result = left.join(':') + '::' + right.join(':'); if (left.length === 0) result = '::' + right.join(':'); if (right.length === 0) result = left.join(':') + '::'; return result; } // 验证并解析 IPv4 地址,返回字节数组或 null function parseIPv4(ipv4) { const parts = ipv4.trim().split('.'); if (parts.length !== 4) return null; const bytes = []; for (let p of parts) { const num = parseInt(p, 10); if (isNaN(num) || num < 0 || num > 255 || p !== num.toString()) return null; bytes.push(num); } return bytes; } // 获取当前选中的前缀字符串(去除 /96) function getCurrentPrefix() { if (prefixSelect.value === 'custom') { let val = customPrefixInput.value.trim(); if (!val) return null; // 允许带 /96 或不带 if (val.endsWith('/96')) val = val.slice(0, -3); return val; } else { let val = prefixSelect.value; if (val.endsWith('/96')) val = val.slice(0, -3); return val; } } // 执行转换并更新界面 function updateConversion() { const ipv4 = ipv4Input.value.trim(); const prefixStr = getCurrentPrefix(); // 清除旧错误 errorMessage.textContent = ''; detailMapping.textContent = ''; if (!ipv4) { resultIPv6.textContent = '请输入 IPv4 地址'; return; } const bytes = parseIPv4(ipv4); if (!bytes) { errorMessage.textContent = '❌ IPv4 地址格式无效,请输入形如 192.0.2.1 的地址'; resultIPv6.textContent = '—'; return; } if (!prefixStr) { errorMessage.textContent = '❌ 请选择或输入有效的 NAT64 前缀'; resultIPv6.textContent = '—'; return; } // 展开前缀 const expanded = expandIPv6(prefixStr); if (!expanded || expanded.length !== 8) { errorMessage.textContent = '❌ IPv6 前缀格式无效或不是 /96 长度'; resultIPv6.textContent = '—'; return; } // IPv4 字节转十六进制组合 const hexParts = bytes.map(b => b.toString(16).padStart(2, '0')); const block6 = parseInt(hexParts[0] + hexParts[1], 16); const block7 = parseInt(hexParts[2] + hexParts[3], 16); // 替换最后两个块 expanded[6] = block6; expanded[7] = block7; const resultAddr = compressIPv6(expanded); resultIPv6.textContent = resultAddr; // 显示转换细节 detailMapping.innerHTML = ` IPv4 十进制: ${bytes.join('.')}<br> 十六进制映射: ${bytes[0]} → 0x${hexParts[0]}, ${bytes[1]} → 0x${hexParts[1]}, ${bytes[2]} → 0x${hexParts[2]}, ${bytes[3]} → 0x${hexParts[3]}<br> 嵌入块: 0x${hexParts[0]}${hexParts[1]} : 0x${hexParts[2]}${hexParts[3]} → <strong>${hexParts[0]}${hexParts[1]}:${hexParts[2]}${hexParts[3]}</strong> `; } // 切换自定义前缀显示 function toggleCustomPrefix() { if (prefixSelect.value === 'custom') { customPrefixWrapper.classList.add('show'); } else { customPrefixWrapper.classList.remove('show'); } updateConversion(); } // 事件监听 ipv4Input.addEventListener('input', updateConversion); prefixSelect.addEventListener('change', toggleCustomPrefix); customPrefixInput.addEventListener('input', updateConversion); // 初始调用 toggleCustomPrefix(); updateConversion(); })(); </script> </body> </html> 3 个帖子 - 2 位参与者 阅读完整话题
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>图片拼接工具</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 20px; } .container { max-width: 1200px; margin: 0 auto; background: white; border-radius: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); overflow: hidden; } header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; } header h1 { font-size: 2.5em; margin-bottom: 10px; } header p { opacity: 0.9; font-size: 1.1em; } .main-content { display: flex; gap: 30px; padding: 30px; } .left-panel { flex: 1; min-width: 300px; } .right-panel { flex: 2; min-width: 500px; } .section { background: #f8f9fa; border-radius: 15px; padding: 25px; margin-bottom: 20px; } .section h2 { color: #333; margin-bottom: 20px; font-size: 1.3em; display: flex; align-items: center; gap: 10px; } .section h2::before { content: ''; width: 4px; height: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 2px; } /* 上传区域 */ .upload-area { border: 3px dashed #ddd; border-radius: 15px; padding: 40px 20px; text-align: center; cursor: pointer; transition: all 0.3s ease; background: white; } .upload-area:hover { border-color: #667eea; background: #f0f4ff; } .upload-area.dragover { border-color: #667eea; background: #e8eeff; transform: scale(1.02); } .upload-icon { font-size: 60px; margin-bottom: 15px; } .upload-text { color: #666; font-size: 1.1em; } .upload-hint { color: #999; font-size: 0.9em; margin-top: 10px; } input[type="file"] { display: none; } /* 布局选项 */ .layout-options { display: grid; grid-template-columns: repeat(2, 1fr); gap: 15px; } .layout-option { background: white; border: 2px solid #e0e0e0; border-radius: 12px; padding: 15px; cursor: pointer; transition: all 0.3s ease; text-align: center; } .layout-option:hover { border-color: #667eea; transform: translateY(-2px); } .layout-option.active { border-color: #667eea; background: linear-gradient(135deg, #667eea10 0%, #764ba210 100%); } .layout-preview { display: flex; justify-content: center; align-items: center; height: 50px; margin-bottom: 10px; } .layout-preview .box { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 4px; } /* 上下排列 */ .layout-v .box { width: 30px; height: 15px; margin: 2px 0; } /* 左右排列 */ .layout-h .box { width: 15px; height: 30px; margin: 0 2px; } /* 2x2 网格 */ .layout-2x2 { display: grid !important; grid-template-columns: repeat(2, 1fr); gap: 3px; width: fit-content; } .layout-2x2 .box { width: 18px; height: 18px; } /* 3x3 网格 */ .layout-3x3 { display: grid !important; grid-template-columns: repeat(3, 1fr); gap: 2px; width: fit-content; } .layout-3x3 .box { width: 12px; height: 12px; } /* 4x4 网格 */ .layout-4x4 { display: grid !important; grid-template-columns: repeat(4, 1fr); gap: 2px; width: fit-content; } .layout-4x4 .box { width: 10px; height: 10px; } .layout-name { font-weight: 600; color: #333; font-size: 0.95em; } /* 间距控制 */ .gap-control { margin-top: 15px; } .gap-control label { display: block; margin-bottom: 10px; color: #555; font-weight: 500; } .gap-control input[type="range"] { width: 100%; height: 8px; border-radius: 4px; background: #e0e0e0; outline: none; -webkit-appearance: none; } .gap-control input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,0.2); } .gap-value { text-align: center; margin-top: 8px; color: #667eea; font-weight: 600; } /* 背景色控制 */ .bg-control { margin-top: 20px; } .bg-control label { display: block; margin-bottom: 10px; color: #555; font-weight: 500; } .color-options { display: flex; gap: 10px; flex-wrap: wrap; } .color-option { width: 35px; height: 35px; border-radius: 8px; cursor: pointer; border: 3px solid transparent; transition: all 0.3s ease; } .color-option:hover { transform: scale(1.1); } .color-option.active { border-color: #333; } /* 图片列表 */ .image-list { max-height: 300px; overflow-y: auto; padding-right: 10px; } .image-list::-webkit-scrollbar { width: 8px; } .image-list::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; } .image-list::-webkit-scrollbar-thumb { background: #667eea; border-radius: 4px; } .image-item { display: flex; align-items: center; gap: 15px; background: white; padding: 12px; border-radius: 10px; margin-bottom: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .image-item img { width: 60px; height: 60px; object-fit: cover; border-radius: 8px; } .image-info { flex: 1; overflow: hidden; } .image-name { font-weight: 600; color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .image-size { color: #888; font-size: 0.85em; margin-top: 3px; } .image-actions { display: flex; gap: 5px; } .btn-icon { width: 32px; height: 32px; border: none; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; font-size: 16px; } .btn-move { background: #e8f4fd; color: #2196F3; } .btn-move:hover { background: #2196F3; color: white; } .btn-delete { background: #ffebee; color: #f44336; } .btn-delete:hover { background: #f44336; color: white; } /* 预览区域 */ .preview-area { background: #f5f5f5; border-radius: 15px; min-height: 400px; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 20px; } .preview-placeholder { text-align: center; color: #999; } .preview-placeholder .icon { font-size: 80px; margin-bottom: 20px; } #previewCanvas { max-width: 100%; max-height: 600px; border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); } /* 按钮样式 */ .btn { padding: 15px 30px; border: none; border-radius: 12px; font-size: 1.1em; font-weight: 600; cursor: pointer; transition: all 0.3s ease; display: inline-flex; align-items: center; gap: 10px; } .btn-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); } .btn-primary:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5); } .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } .btn-secondary { background: #f0f0f0; color: #333; } .btn-secondary:hover { background: #e0e0e0; } .button-group { display: flex; gap: 15px; margin-top: 20px; flex-wrap: wrap; } /* 空状态 */ .empty-state { text-align: center; padding: 40px; color: #999; } .empty-state .icon { font-size: 60px; margin-bottom: 15px; } /* 响应式 */ @media (max-width: 900px) { .main-content { flex-direction: column; } .left-panel, .right-panel { min-width: 100%; } } /* 动画 */ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .loading { animation: pulse 1.5s infinite; } /* 图片尺寸设置 */ .size-control { margin-top: 20px; } .size-control label { display: block; margin-bottom: 10px; color: #555; font-weight: 500; } .size-inputs { display: flex; gap: 15px; align-items: center; } .size-input { flex: 1; } .size-input input { width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 1em; transition: border-color 0.3s; } .size-input input:focus { outline: none; border-color: #667eea; } .size-separator { font-size: 1.2em; color: #999; } .size-locked { display: flex; align-items: center; gap: 8px; margin-top: 10px; } .size-locked input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; } .size-locked label { cursor: pointer; margin: 0; } /* 输出格式选择 */ .format-control { margin-top: 20px; } .format-control label { display: block; margin-bottom: 10px; color: #555; font-weight: 500; } .format-options { display: flex; gap: 10px; } .format-option { flex: 1; padding: 12px; background: white; border: 2px solid #e0e0e0; border-radius: 10px; cursor: pointer; text-align: center; transition: all 0.3s ease; } .format-option:hover { border-color: #667eea; } .format-option.active { border-color: #667eea; background: linear-gradient(135deg, #667eea10 0%, #764ba210 100%); } .format-option input { display: none; } .format-option span { font-weight: 600; color: #333; } </style> </head> <body> <div class="container"> <header> <h1>🖼️ 图片拼接工具</h1> <p>支持多种排列方式,无缝拼接,一键导出</p> </header> <div class="main-content"> <div class="left-panel"> <!-- 上传区域 --> <div class="section"> <h2>上传图片</h2> <div class="upload-area" id="uploadArea"> <div class="upload-icon">📁</div> <div class="upload-text">点击或拖拽图片到这里</div> <div class="upload-hint">支持 JPG、PNG、GIF、WebP 格式</div> </div> <input type="file" id="fileInput" accept="image/*" multiple> </div> <!-- 布局设置 --> <div class="section"> <h2>布局方式</h2> <div class="layout-options"> <div class="layout-option active" data-layout="vertical"> <div class="layout-preview layout-v"> <div class="box"></div> <div class="box"></div> </div> <div class="layout-name">上 → 下</div> </div> <div class="layout-option" data-layout="horizontal"> <div class="layout-preview layout-h"> <div class="box"></div> <div class="box"></div> </div> <div class="layout-name">左 → 右</div> </div> <div class="layout-option" data-layout="2x2"> <div class="layout-preview layout-2x2"> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> </div> <div class="layout-name">2 × 2</div> </div> <div class="layout-option" data-layout="3x3"> <div class="layout-preview layout-3x3"> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> </div> <div class="layout-name">3 × 3</div> </div> <div class="layout-option" data-layout="4x4"> <div class="layout-preview layout-4x4"> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> <div class="box"></div> </div> <div class="layout-name">4 × 4</div> </div> </div> <!-- 间距控制 --> <div class="gap-control"> <label>图片间距:0 px(无缝拼接)</label> <input type="range" id="gapRange" min="0" max="50" value="0"> <div class="gap-value" id="gapValue">0 px</div> </div> <!-- 背景色控制 --> <div class="bg-control"> <label>背景颜色</label> <div class="color-options"> <div class="color-option active" style="background: #ffffff;" data-color="#ffffff"></div> <div class="color-option" style="background: #000000;" data-color="#000000"></div> <div class="color-option" style="background: #f5f5f5;" data-color="#f5f5f5"></div> <div class="color-option" style="background: #333333;" data-color="#333333"></div> <div class="color-option" style="background: #ff6b6b;" data-color="#ff6b6b"></div> <div class="color-option" style="background: #4ecdc4;" data-color="#4ecdc4"></div> <div class="color-option" style="background: #667eea;" data-color="#667eea"></div> <div class="color-option" style="background: #764ba2;" data-color="#764ba2"></div> </div> </div> <!-- 输出格式 --> <div class="format-control"> <label>输出格式</label> <div class="format-options"> <label class="format-option active" data-format="png"> <input type="radio" name="format" value="png" checked> <span>PNG</span> </label> <label class="format-option" data-format="jpeg"> <input type="radio" name="format" value="jpeg"> <span>JPEG</span> </label> <label class="format-option" data-format="webp"> <input type="radio" name="format" value="webp"> <span>WebP</span> </label> </div> </div> </div> <!-- 图片列表 --> <div class="section"> <h2>图片列表 <span id="imageCount">(0)</span></h2> <div class="image-list" id="imageList"> <div class="empty-state"> <div class="icon">📷</div> <div>还没有添加图片</div> </div> </div> </div> </div> <div class="right-panel"> <div class="section" style="min-height: 500px;"> <h2>预览效果</h2> <div class="preview-area" id="previewArea"> <div class="preview-placeholder" id="previewPlaceholder"> <div class="icon">🖼️</div> <div>上传图片后预览拼接效果</div> </div> <canvas id="previewCanvas" style="display: none;"></canvas> </div> <div class="button-group"> <button class="btn btn-primary" id="mergeBtn" disabled> 🎨 生成拼接图片 </button> <button class="btn btn-secondary" id="downloadBtn" disabled> 💾 下载图片 </button> <button class="btn btn-secondary" id="clearBtn"> 🗑️ 清空图片 </button> </div> </div> </div> </div> </div> <script> // 全局变量 let images = []; let currentLayout = 'vertical'; let gap = 0; let bgColor = '#ffffff'; let outputFormat = 'png'; let mergedImageData = null; // DOM 元素 const uploadArea = document.getElementById('uploadArea'); const fileInput = document.getElementById('fileInput'); const imageList = document.getElementById('imageList'); const imageCount = document.getElementById('imageCount'); const previewArea = document.getElementById('previewArea'); const previewPlaceholder = document.getElementById('previewPlaceholder'); const previewCanvas = document.getElementById('previewCanvas'); const mergeBtn = document.getElementById('mergeBtn'); const downloadBtn = document.getElementById('downloadBtn'); const clearBtn = document.getElementById('clearBtn'); const gapRange = document.getElementById('gapRange'); const gapValue = document.getElementById('gapValue'); // 上传事件 uploadArea.addEventListener('click', () => fileInput.click()); uploadArea.addEventListener('dragover', (e) => { e.preventDefault(); uploadArea.classList.add('dragover'); }); uploadArea.addEventListener('dragleave', () => { uploadArea.classList.remove('dragover'); }); uploadArea.addEventListener('drop', (e) => { e.preventDefault(); uploadArea.classList.remove('dragover'); handleFiles(e.dataTransfer.files); }); fileInput.addEventListener('change', (e) => { handleFiles(e.target.files); fileInput.value = ''; }); // 处理文件 function handleFiles(files) { Array.from(files).forEach(file => { if (file.type.startsWith('image/')) { const reader = new FileReader(); reader.onload = (e) => { const img = new Image(); img.onload = () => { images.push({ id: Date.now() + Math.random(), name: file.name, size: file.size, width: img.width, height: img.height, src: e.target.result, img: img }); updateUI(); }; img.src = e.target.result; }; reader.readAsDataURL(file); } }); } // 更新UI function updateUI() { imageCount.textContent = `(${images.length})`; mergeBtn.disabled = images.length === 0; if (images.length === 0) { imageList.innerHTML = ` <div class="empty-state"> <div class="icon">📷</div> <div>还没有添加图片</div> </div> `; previewPlaceholder.style.display = 'block'; previewCanvas.style.display = 'none'; } else { renderImageList(); previewMerge(); } } // 渲染图片列表 function renderImageList() { imageList.innerHTML = images.map((img, index) => ` <div class="image-item" data-id="${img.id}"> <img src="${img.src}" alt="${img.name}"> <div class="image-info"> <div class="image-name">${img.name}</div> <div class="image-size">${img.width} × ${img.height} · ${formatSize(img.size)}</div> </div> <div class="image-actions"> <button class="btn-icon btn-move" onclick="moveImage(${index}, -1)" ${index === 0 ? 'disabled' : ''}>↑</button> <button class="btn-icon btn-move" onclick="moveImage(${index}, 1)" ${index === images.length - 1 ? 'disabled' : ''}>↓</button> <button class="btn-icon btn-delete" onclick="removeImage(${index})">×</button> </div> </div> `).join(''); } // 移动图片 function moveImage(index, direction) { const newIndex = index + direction; if (newIndex < 0 || newIndex >= images.length) return; [images[index], images[newIndex]] = [images[newIndex], images[index]]; renderImageList(); previewMerge(); } // 删除图片 function removeImage(index) { images.splice(index, 1); updateUI(); } // 格式化文件大小 function formatSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } // 布局选择 document.querySelectorAll('.layout-option').forEach(option => { option.addEventListener('click', () => { document.querySelectorAll('.layout-option').forEach(o => o.classList.remove('active')); option.classList.add('active'); currentLayout = option.dataset.layout; previewMerge(); }); }); // 间距控制 gapRange.addEventListener('input', (e) => { gap = parseInt(e.target.value); gapValue.textContent = gap + ' px'; previewMerge(); }); // 背景色选择 document.querySelectorAll('.color-option').forEach(option => { option.addEventListener('click', () => { document.querySelectorAll('.color-option').forEach(o => o.classList.remove('active')); option.classList.add('active'); bgColor = option.dataset.color; previewMerge(); }); }); // 输出格式选择 document.querySelectorAll('.format-option').forEach(option => { option.addEventListener('click', () => { document.querySelectorAll('.format-option').forEach(o => o.classList.remove('active')); option.classList.add('active'); outputFormat = option.dataset.format; }); }); // 预览拼接 function previewMerge() { if (images.length === 0) { previewPlaceholder.style.display = 'block'; previewCanvas.style.display = 'none'; return; } const ctx = previewCanvas.getContext('2d'); const result = calculateLayout(); previewCanvas.width = result.canvasWidth; previewCanvas.height = result.canvasHeight; previewCanvas.style.display = 'block'; previewPlaceholder.style.display = 'none'; // 绘制背景 ctx.fillStyle = bgColor; ctx.fillRect(0, 0, result.canvasWidth, result.canvasHeight); // 绘制图片 result.positions.forEach(pos => { const img = images[pos.index]; if (img && img.img) { ctx.drawImage(img.img, pos.x, pos.y, pos.width, pos.height); } }); mergedImageData = result; } // 计算布局 function calculateLayout() { const positions = []; let canvasWidth = 0; let canvasHeight = 0; if (currentLayout === 'vertical') { // 上下排列 - 统一宽度 const maxWidth = Math.max(...images.map(img => img.width)); let y = 0; images.forEach((img, index) => { const scale = maxWidth / img.width; const width = maxWidth; const height = img.height * scale; positions.push({ index, x: 0, y, width, height }); y += height + gap; canvasWidth = maxWidth; }); canvasHeight = y - gap; } else if (currentLayout === 'horizontal') { // 左右排列 - 统一高度 const maxHeight = Math.max(...images.map(img => img.height)); let x = 0; images.forEach((img, index) => { const scale = maxHeight / img.height; const width = img.width * scale; const height = maxHeight; positions.push({ index, x, y: 0, width, height }); x += width + gap; }); canvasWidth = x - gap; canvasHeight = maxHeight; } else { // 网格排列 const gridSize = parseInt(currentLayout.split('x')[0]); const cellWidth = Math.max(...images.map(img => img.width)); const cellHeight = Math.max(...images.map(img => img.height)); images.forEach((img, index) => { const row = Math.floor(index / gridSize); const col = index % gridSize; const x = col * (cellWidth + gap); const y = row * (cellHeight + gap); positions.push({ index, x, y, width: cellWidth, height: cellHeight }); }); const rows = Math.ceil(images.length / gridSize); canvasWidth = gridSize * cellWidth + (gridSize - 1) * gap; canvasHeight = rows * cellHeight + (rows - 1) * gap; } return { canvasWidth, canvasHeight, positions }; } // 生成拼接图片 mergeBtn.addEventListener('click', () => { previewMerge(); downloadBtn.disabled = false; }); // 下载图片 downloadBtn.addEventListener('click', () => { if (!mergedImageData) return; const mimeType = `image/${outputFormat}`; const extension = outputFormat === 'jpeg' ? 'jpg' : outputFormat; const filename = `merged_${Date.now()}.${extension}`; const link = document.createElement('a'); link.download = filename; link.href = previewCanvas.toDataURL(mimeType, 0.95); link.click(); }); // 清空图片 clearBtn.addEventListener('click', () => { images = []; updateUI(); downloadBtn.disabled = true; }); // 初始加载提示 console.log('🖼️ 图片拼接工具已就绪'); </script> </body> </html> 现成的不能符合我的需求,我就Vibe了一个,需要的拿走不谢 1 个帖子 - 1 位参与者 阅读完整话题
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes"> <title>AES-256-CBC 加解密工具 | 在线对称加密</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { background: linear-gradient(145deg, #1a1e2b 0%, #2a2f3f 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; font-family: 'Segoe UI', Roboto, system-ui, -apple-system, sans-serif; padding: 1.5rem; margin: 0; } .container { max-width: 750px; width: 100%; background: rgba(255, 255, 255, 0.07); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); border-radius: 2.5rem; padding: 2.2rem 2rem; box-shadow: 0 30px 50px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.15); transition: all 0.2s ease; } h1 { text-align: center; font-weight: 500; font-size: 2.1rem; letter-spacing: 2px; color: #e0e5f0; margin-bottom: 0.3rem; display: flex; align-items: center; justify-content: center; gap: 0.5rem; } h1 span { background: #3b82f6; color: white; font-size: 1rem; font-weight: 600; padding: 0.2rem 0.9rem; border-radius: 30px; letter-spacing: 0.5px; } .subtitle { text-align: center; color: #9aa4bf; margin-bottom: 2.2rem; font-size: 0.95rem; border-bottom: 1px dashed rgba(255,255,255,0.2); padding-bottom: 1.2rem; } .field { margin-bottom: 1.5rem; } label { display: flex; align-items: center; gap: 0.4rem; font-weight: 500; color: #cbd5e1; margin-bottom: 0.5rem; font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.4px; } label i { font-style: normal; font-size: 1rem; } textarea, input { width: 100%; background: rgba(10, 15, 25, 0.7); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 1.2rem; padding: 0.9rem 1.2rem; font-size: 0.95rem; color: #f1f5f9; outline: none; transition: all 0.25s; font-family: 'Fira Code', 'JetBrains Mono', monospace; resize: vertical; backdrop-filter: blur(4px); } textarea:focus, input:focus { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.35); background: rgba(20, 25, 40, 0.8); } textarea { min-height: 100px; } .key-wrapper { display: flex; gap: 0.6rem; align-items: center; } .key-wrapper input { flex: 1; } .icon-btn { background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.25); color: #cbd5e1; padding: 0.7rem 1rem; border-radius: 1rem; font-size: 1.1rem; cursor: pointer; transition: 0.2s; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(8px); } .icon-btn:hover { background: rgba(59, 130, 246, 0.25); border-color: #3b82f6; color: white; } .actions { display: flex; gap: 1rem; margin: 2rem 0 1.2rem; flex-wrap: wrap; } .btn { flex: 1; min-width: 120px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.2); padding: 0.9rem 1.2rem; border-radius: 1.5rem; font-weight: 600; font-size: 1rem; color: #e2e8f0; cursor: pointer; backdrop-filter: blur(10px); transition: all 0.25s; display: flex; align-items: center; justify-content: center; gap: 0.4rem; letter-spacing: 0.5px; } .btn-encrypt { background: #2563eb; border-color: #3b82f6; box-shadow: 0 8px 18px -6px #1e3a8a; color: white; } .btn-encrypt:hover { background: #1d4ed8; border-color: #60a5fa; box-shadow: 0 10px 22px -6px #1e3a8a; } .btn-decrypt { background: #7c3aed; border-color: #8b5cf6; box-shadow: 0 8px 18px -6px #4c1d95; color: white; } .btn-decrypt:hover { background: #6d28d9; border-color: #a78bfa; } .btn-copy { background: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.25); } .btn-copy:hover { background: rgba(255, 255, 255, 0.18); border-color: #94a3b8; } .info-row { display: flex; justify-content: space-between; align-items: center; margin-top: 0.8rem; font-size: 0.8rem; color: #94a3b8; flex-wrap: wrap; gap: 0.5rem; } .badge { background: rgba(0,0,0,0.4); padding: 0.3rem 1rem; border-radius: 20px; backdrop-filter: blur(4px); } hr { border-color: rgba(255,255,255,0.1); margin: 1.2rem 0 0.8rem; } .footer-note { color: #7f8aa0; font-size: 0.8rem; text-align: center; } @media (max-width: 500px) { .container { padding: 1.5rem; } .actions { flex-direction: column; } } </style> </head> <body> <div class="container"> <h1> 🔐 AES-256-CBC <span>Crypto</span> </h1> <div class="subtitle">使用 Web Crypto API · 安全客户端加解密</div> <!-- 密钥输入 --> <div class="field"> <label><i>🔑</i> 密钥 (32字节 / 256位)</label> <div class="key-wrapper"> <input type="text" id="keyInput" placeholder="输入32字符密钥或点击生成随机密钥" autocomplete="off" spellcheck="false"> <button class="icon-btn" id="generateKeyBtn" title="生成随机256位密钥 (hex)">🎲</button> <button class="icon-btn" id="copyKeyBtn" title="复制密钥">📋</button> </div> <div class="info-row"> <span id="keyLengthIndicator">⚡ 长度: 0 / 32 字节</span> <span class="badge" id="keyStatus">未设置</span> </div> </div> <!-- 明文输入 --> <div class="field"> <label><i>📝</i> 明文 (Plaintext)</label> <textarea id="plaintextInput" placeholder="输入要加密的内容..."></textarea> </div> <!-- 密文输入 (Base64) --> <div class="field"> <label><i>🔒</i> 密文 (Base64格式)</label> <textarea id="ciphertextInput" placeholder="输入Base64密文进行解密..."></textarea> </div> <!-- 操作按钮组 --> <div class="actions"> <button class="btn btn-encrypt" id="encryptBtn">🔒 加密</button> <button class="btn btn-decrypt" id="decryptBtn">🔓 解密</button> <button class="btn btn-copy" id="copyCipherBtn">📋 复制密文</button> </div> <!-- 结果/状态信息 --> <div class="info-row" style="justify-content: center;"> <span id="operationStatus" class="badge" style="background: #1e293b;">⚪ 等待操作</span> </div> <hr> <div class="footer-note"> AES-256-CBC · 每次加密使用随机IV (16字节) · 密文格式: IV + 密文 (Base64) </div> </div> <script> (function() { // DOM 元素 const keyInput = document.getElementById('keyInput'); const plaintextInput = document.getElementById('plaintextInput'); const ciphertextInput = document.getElementById('ciphertextInput'); const encryptBtn = document.getElementById('encryptBtn'); const decryptBtn = document.getElementById('decryptBtn'); const generateKeyBtn = document.getElementById('generateKeyBtn'); const copyKeyBtn = document.getElementById('copyKeyBtn'); const copyCipherBtn = document.getElementById('copyCipherBtn'); const keyLengthIndicator = document.getElementById('keyLengthIndicator'); const keyStatus = document.getElementById('keyStatus'); const operationStatus = document.getElementById('operationStatus'); // ---------- 工具函数 ---------- function hexStringToUint8Array(hexString) { // 移除空格并确保小写 hexString = hexString.replace(/\s+/g, '').toLowerCase(); if (hexString.length % 2 !== 0) { throw new Error('十六进制字符串长度必须为偶数'); } const bytes = new Uint8Array(hexString.length / 2); for (let i = 0; i < hexString.length; i += 2) { const byte = parseInt(hexString.substr(i, 2), 16); if (isNaN(byte)) throw new Error('无效的十六进制字符'); bytes[i / 2] = byte; } return bytes; } function uint8ArrayToHexString(uint8Array) { return Array.from(uint8Array) .map(b => b.toString(16).padStart(2, '0')) .join(''); } // 生成随机16字节IV (用于CBC) function generateRandomIV() { return crypto.getRandomValues(new Uint8Array(16)); } // 将Base64字符串转换为Uint8Array function base64ToUint8Array(base64) { try { const binaryString = atob(base64); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes; } catch (e) { throw new Error('Base64解码失败:无效的Base64字符串'); } } // 将Uint8Array转换为Base64 function uint8ArrayToBase64(uint8Array) { let binaryString = ''; uint8Array.forEach(byte => { binaryString += String.fromCharCode(byte); }); return btoa(binaryString); } // 验证并获取CryptoKey (AES-256-CBC) async function getCryptoKeyFromHex(hexKey) { if (!hexKey || hexKey.trim() === '') { throw new Error('密钥不能为空'); } const cleanHex = hexKey.replace(/\s+/g, ''); if (cleanHex.length !== 64) { throw new Error(`密钥长度必须为64个十六进制字符 (32字节),当前长度: ${cleanHex.length}`); } if (!/^[0-9a-fA-F]{64}$/.test(cleanHex)) { throw new Error('密钥包含无效的十六进制字符'); } const rawKey = hexStringToUint8Array(cleanHex); return await crypto.subtle.importKey( 'raw', rawKey, { name: 'AES-CBC' }, false, ['encrypt', 'decrypt'] ); } // 更新密钥状态显示 function updateKeyIndicator() { const rawValue = keyInput.value.replace(/\s+/g, ''); const byteLength = rawValue.length / 2; keyLengthIndicator.textContent = `⚡ 长度: ${byteLength} / 32 字节 (${rawValue.length} hex字符)`; if (rawValue.length === 0) { keyStatus.textContent = '未设置'; keyStatus.style.color = '#f87171'; } else if (rawValue.length === 64 && /^[0-9a-fA-F]{64}$/.test(rawValue)) { keyStatus.textContent = '✅ 有效256位密钥'; keyStatus.style.color = '#4ade80'; } else { keyStatus.textContent = '❌ 格式无效'; keyStatus.style.color = '#fbbf24'; } } // 生成随机256位密钥 (hex) function generateRandomHexKey() { const randomBytes = new Uint8Array(32); crypto.getRandomValues(randomBytes); return uint8ArrayToHexString(randomBytes); } // 设置操作状态 function setStatus(message, isError = false) { operationStatus.textContent = message; operationStatus.style.color = isError ? '#fca5a5' : '#e2e8f0'; if (isError) { operationStatus.style.background = '#7f1d1d'; } else { operationStatus.style.background = '#1e293b'; } } // ---------- 加密操作 ---------- async function performEncrypt() { try { const plaintext = plaintextInput.value; if (plaintext === '') { throw new Error('明文不能为空'); } const keyHex = keyInput.value.trim(); const cryptoKey = await getCryptoKeyFromHex(keyHex); // 生成随机IV const iv = generateRandomIV(); // 将明文编码为Uint8Array (UTF-8) const encoder = new TextEncoder(); const plaintextBytes = encoder.encode(plaintext); // 执行加密 const encryptedBuffer = await crypto.subtle.encrypt( { name: 'AES-CBC', iv: iv }, cryptoKey, plaintextBytes ); // 组合 IV + 密文 const encryptedBytes = new Uint8Array(encryptedBuffer); const combined = new Uint8Array(iv.length + encryptedBytes.length); combined.set(iv, 0); combined.set(encryptedBytes, iv.length); // 转换为Base64 const base64Cipher = uint8ArrayToBase64(combined); ciphertextInput.value = base64Cipher; setStatus('✅ 加密成功 (IV已前置)'); } catch (error) { console.error('加密失败:', error); setStatus(`加密失败: ${error.message}`, true); // 不清空密文框,但提示错误 } } // ---------- 解密操作 ---------- async function performDecrypt() { try { const cipherBase64 = ciphertextInput.value.trim(); if (cipherBase64 === '') { throw new Error('密文不能为空'); } const keyHex = keyInput.value.trim(); const cryptoKey = await getCryptoKeyFromHex(keyHex); // 解码Base64得到 IV + 密文 const combined = base64ToUint8Array(cipherBase64); // 检查最小长度:至少需要16字节IV + 16字节块 (AES块大小) if (combined.length < 32) { throw new Error('密文数据太短,必须包含16字节IV和至少一个加密块'); } // 提取IV (前16字节) const iv = combined.slice(0, 16); // 提取密文 (剩余部分) const cipherData = combined.slice(16); // 执行解密 const decryptedBuffer = await crypto.subtle.decrypt( { name: 'AES-CBC', iv: iv }, cryptoKey, cipherData ); // 解码为UTF-8字符串 const decoder = new TextDecoder(); const plaintext = decoder.decode(decryptedBuffer); plaintextInput.value = plaintext; setStatus('🔓 解密成功'); } catch (error) { console.error('解密失败:', error); setStatus(`解密失败: ${error.message}`, true); // 解密失败不修改明文框 } } // 复制到剪贴板 async function copyToClipboard(text, elementDescription = '内容') { if (!text || text.trim() === '') { setStatus(`⚠️ 没有可复制的${elementDescription}`, true); return; } try { await navigator.clipboard.writeText(text); setStatus(`📋 已复制${elementDescription}到剪贴板`); } catch (err) { setStatus(`❌ 复制失败: ${err.message}`, true); } } // ---------- 事件绑定 ---------- keyInput.addEventListener('input', updateKeyIndicator); generateKeyBtn.addEventListener('click', () => { const newKey = generateRandomHexKey(); keyInput.value = newKey; updateKeyIndicator(); setStatus('🎲 已生成随机256位密钥'); }); copyKeyBtn.addEventListener('click', () => { const keyValue = keyInput.value.trim(); copyToClipboard(keyValue, '密钥'); }); encryptBtn.addEventListener('click', performEncrypt); decryptBtn.addEventListener('click', performDecrypt); copyCipherBtn.addEventListener('click', () => { const cipherValue = ciphertextInput.value.trim(); copyToClipboard(cipherValue, '密文'); }); // 可选:回车快捷操作(在密文框按Ctrl+Enter尝试解密,明文框Ctrl+Enter加密) plaintextInput.addEventListener('keydown', (e) => { if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); performEncrypt(); } }); ciphertextInput.addEventListener('keydown', (e) => { if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); performDecrypt(); } }); // 初始化状态显示 updateKeyIndicator(); // 设置一个默认示例密钥(方便测试,但提示用户可自行生成) // 使用一个固定的示例密钥 (32字节 hex) : "a1b2c3d4e5f6071829aabbccddeeff00112233445566778899aabbccddeeff" const defaultKey = "a1b2c3d4e5f6071829aabbccddeeff00112233445566778899aabbccddeeff"; if (keyInput.value === '') { keyInput.value = defaultKey; updateKeyIndicator(); setStatus('ℹ️ 已加载示例密钥,建议生成随机密钥'); } })(); </script> </body> </html> 在线使用 a5a3e081.pinme.dev AES-256-CBC 加解密工具 | 在线对称加密 6 个帖子 - 3 位参与者 阅读完整话题