如题 首页: https://www.ccopyright.com.cn/ 登录页面: https://register.ccopyright.com.cn/login.html 为什么我登录不了呢?网页进去是空白的。 F12看了一下显示,这两个资源502 https://static.ccopyright.com.cn/js/common.js https://static.ccopyright.com.cn/js/browser.js 4 个帖子 - 4 位参与者 阅读完整话题
好像都是 html 、canvas ? 用的啥框架做的,动画效果也挺好看的。
好像都是 html 、canvas ? 用的啥框架做的,动画效果也挺好看的。
好像都是 html 、canvas ? 用的啥框架做的,动画效果也挺好看的。
好像都是 html 、canvas ? 用的啥框架做的,动画效果也挺好看的。
claude-fable-5 一句话生成了一个html游戏,特效碰撞都有了音效也有,简直太牛了 唯一的缺点就是贵! 14 个帖子 - 12 位参与者 阅读完整话题
本来所有资料都是转markdown的,最近有人推荐用html,这么卷下去以后岂不是要转latex? 1 个帖子 - 1 位参与者 阅读完整话题
<!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>证件照换底 - 离线隐私处理</title> <style> *{margin:0;padding:0;box-sizing:border-box;} body{ font-family: -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif; background:#f0f2f5;min-height:100vh;color:#1a1a2e;line-height:1.6; } .header{ background:#fff;border-bottom:1px solid #e0e0e0;padding:14px 0; position:sticky;top:0;z-index:100;box-shadow:0 1px 3px rgba(0,0,0,0.06); } .header-inner{max-width:1200px;margin:0 auto;padding:0 24px;display:flex;align-items:center;justify-content:space-between;} .logo{display:flex;align-items:center;gap:10px;font-size:1.25rem;font-weight:700;} .logo-icon{width:38px;height:38px;border-radius:11px;background:linear-gradient(135deg,#4f6ef7,#7b8ff7);display:flex;align-items:center;justify-content:center;font-size:1.3rem;color:#fff;} .privacy-badge{display:flex;align-items:center;gap:6px;font-size:0.8rem;color:#27ae60;background:#eafaf1;padding:5px 12px;border-radius:20px;font-weight:600;} .privacy-dot{width:7px;height:7px;border-radius:50%;background:#27ae60;animation:pulse 2s infinite;} @keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}} .main-container{max-width:1200px;margin:0 auto;padding:24px;display:grid;grid-template-columns:1fr 1fr;gap:24px;} @media(max-width:900px){.main-container{grid-template-columns:1fr;max-width:520px;}} .card{background:#fff;border-radius:16px;padding:22px;box-shadow:0 4px 24px rgba(0,0,0,0.08);border:1px solid #e0e0e0;} .card-title{font-size:1rem;font-weight:700;margin-bottom:16px;} .upload-zone{border:2px dashed #d0d5e0;border-radius:10px;padding:36px 20px;text-align:center;cursor:pointer;background:#fafbfc;position:relative;min-height:180px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;transition:0.25s;} .upload-zone:hover,.upload-zone.drag-over{border-color:#4f6ef7;background:#eef1ff;} .upload-zone.has-image{border-style:solid;border-color:#e0e0e0;padding:6px;min-height:auto;} .upload-icon{font-size:2.8rem;opacity:0.5;} .upload-text{font-size:0.9rem;color:#555;} .upload-hint{font-size:0.75rem;color:#999;} .upload-zone input[type="file"]{position:absolute;inset:0;opacity:0;cursor:pointer;} .preview-image{width:100%;max-height:350px;object-fit:contain;display:block;border-radius:5px;} .image-container{position:relative;display:block;width:100%;} .image-actions-overlay{position:absolute;top:8px;right:8px;z-index:5;} .btn-icon-sm{width:32px;height:32px;border-radius:50%;border:none;background:rgba(0,0,0,0.5);color:#fff;cursor:pointer;font-size:0.85rem;display:flex;align-items:center;justify-content:center;} .btn{display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:9px 18px;border-radius:25px;font-size:0.88rem;font-weight:600;cursor:pointer;border:none;transition:0.25s;white-space:nowrap;} .btn:active{transform:scale(0.96);} .btn-primary{background:#4f6ef7;color:#fff;} .btn-primary:hover{background:#3b54db;} .btn-outline{background:#fff;color:#4f6ef7;border:2px solid #4f6ef7;} .btn-outline:hover{background:#eef1ff;} .btn-success{background:#27ae60;color:#fff;} .btn-success:hover{opacity:0.9;} .btn-lg{padding:11px 26px;font-size:0.95rem;border-radius:28px;} .btn:disabled{opacity:0.5;cursor:not-allowed;pointer-events:none;} .btn-block{width:100%;} .section-label{font-size:0.82rem;font-weight:600;margin:14px 0 8px;} .color-presets{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:8px;} .color-preset{width:40px;height:40px;border-radius:50%;cursor:pointer;border:3px solid transparent;transition:0.25s;box-shadow:0 1px 3px rgba(0,0,0,0.1);position:relative;} .color-preset:hover{transform:scale(1.12);} .color-preset.active{border-color:#1a1a2e;box-shadow:0 0 0 4px rgba(0,0,0,0.08);} .color-preset.active::after{content:'✓';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:bold;font-size:0.9rem;text-shadow:0 1px 2px rgba(0,0,0,0.5);} .custom-color-section{background:#f8f9fb;border-radius:12px;padding:12px 14px;margin-bottom:4px;border:1px solid #e8eaef;} .custom-color-row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;} .custom-color-row input[type="color"]{width:40px;height:40px;border-radius:50%;border:2px solid #e0e0e0;cursor:pointer;padding:2px;flex-shrink:0;} .custom-color-row input[type="color"]::-webkit-color-swatch-wrapper{padding:0;} .custom-color-row input[type="color"]::-webkit-color-swatch{border-radius:50%;border:none;} .hex-input-wrapper{position:relative;flex:1;min-width:110px;} .hex-input-wrapper .hash{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:#999;font-weight:600;font-size:0.85rem;pointer-events:none;} .hex-input{width:100%;padding:8px 10px 8px 24px;border:2px solid #e0e5ec;border-radius:10px;font-size:0.85rem;font-family:monospace;font-weight:600;color:#1a1a2e;background:#fff;outline:none;transition:0.25s;} .hex-input:focus{border-color:#4f6ef7;box-shadow:0 0 0 3px rgba(79,110,247,0.1);} .hex-input.valid{border-color:#27ae60;background:#f8fdf8;} .hex-input.invalid{border-color:#e74c3c;background:#fef8f8;} .color-preview-dot{width:30px;height:30px;border-radius:50%;border:2px solid #ddd;flex-shrink:0;box-shadow:inset 0 1px 2px rgba(0,0,0,0.1);} .control-group{margin-bottom:12px;} .control-label{display:flex;justify-content:space-between;align-items:center;font-size:0.82rem;font-weight:600;margin-bottom:5px;} .control-value{font-size:0.75rem;color:#555;background:#f5f6f8;padding:2px 10px;border-radius:12px;} input[type="range"]{-webkit-appearance:none;width:100%;height:5px;border-radius:3px;background:#e0e5ec;outline:none;cursor:pointer;} input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;width:20px;height:20px;border-radius:50%;background:#4f6ef7;cursor:pointer;} .size-presets{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px;} .size-preset{padding:10px 12px;border-radius:8px;border:2px solid #e0e0e0;cursor:pointer;text-align:center;font-size:0.82rem;font-weight:600;transition:0.25s;background:#fafbfc;} .size-preset:hover{border-color:#4f6ef7;background:#eef1ff;} .size-preset.active{border-color:#4f6ef7;background:#eef1ff;color:#4f6ef7;} .size-preset .size-dims{font-size:0.7rem;color:#555;font-weight:400;} .result-container{text-align:center;} .result-image{max-width:100%;max-height:350px;border-radius:10px;box-shadow:0 4px 24px rgba(0,0,0,0.08);border:1px solid #e0e0e0;} .result-placeholder{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:220px;color:#bbb;gap:10px;} .result-placeholder .icon{font-size:3.5rem;} .action-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px;} .toast{position:fixed;bottom:30px;left:50%;transform:translateX(-50%) translateY(100px);background:#1a1a2e;color:#fff;padding:10px 22px;border-radius:25px;font-size:0.85rem;font-weight:600;z-index:999;opacity:0;transition:all 0.35s;pointer-events:none;box-shadow:0 8px 30px rgba(0,0,0,0.25);} .toast.show{opacity:1;transform:translateX(-50%) translateY(0);} .toast.success{background:#27ae60;} .toast.error{background:#e74c3c;} .spinner{display:inline-block;width:16px;height:16px;border:2px solid rgba(255,255,255,0.3);border-top-color:#fff;border-radius:50%;animation:spin 0.7s linear infinite;} @keyframes spin{to{transform:rotate(360deg);}} .footer{text-align:center;padding:18px;font-size:0.75rem;color:#aaa;} </style> </head> <body> <header class="header"> <div class="header-inner"> <div class="logo"><div class="logo-icon">📸</div><span>证件照换底</span></div> <div class="privacy-badge"><span class="privacy-dot"></span>离线处理 · 隐私安全</div> </div> </header> <div class="main-container"> <!-- ========== 左侧面板 ========== --> <div class="card"> <div class="card-title">⚙️ 上传与设置</div> <div class="upload-zone" id="uploadZone"> <span class="upload-icon">📷</span> <span class="upload-text">点击或拖拽上传证件照</span> <span class="upload-hint">支持 JPG / PNG / WebP</span> <input type="file" id="fileInput" accept="image/*"> </div> <div class="section-label">🎨 背景颜色(实时预览)</div> <div class="color-presets" id="colorPresets"> <div class="color-preset active" data-color="#FF0000" style="background:#FF0000;" title="红色"></div> <div class="color-preset" data-color="#FFFFFF" style="background:#FFFFFF;" title="白色"></div> <div class="color-preset" data-color="#438EDB" style="background:#438EDB;" title="蓝色"></div> <div class="color-preset" data-color="#1AAD19" style="background:#1AAD19;" title="绿色"></div> <div class="color-preset" data-color="#808080" style="background:#808080;" title="灰色"></div> <div class="color-preset" data-color="#003399" style="background:#003399;" title="深蓝"></div> </div> <div class="custom-color-section"> <div style="font-size:0.78rem;font-weight:600;margin-bottom:8px;color:#555;">🎯 自定义颜色</div> <div class="custom-color-row"> <input type="color" id="customColorPicker" value="#FF0000" title="取色器"> <div class="hex-input-wrapper"> <span class="hash">#</span> <input type="text" class="hex-input valid" id="hexInput" value="FF0000" maxlength="6" placeholder="输入6位HEX色值"> </div> <div class="color-preview-dot" id="colorPreviewDot" style="background:#FF0000;" title="当前颜色"></div> </div> </div> <div class="control-group" style="margin-top:14px;"> <div class="control-label"><span>🔍 容差范围</span><span class="control-value" id="toleranceVal">50</span></div> <input type="range" id="toleranceSlider" min="10" max="120" value="50" step="1"> </div> <div class="control-group"> <div class="control-label"><span>✨ 边缘羽化</span><span class="control-value" id="featherVal">1</span></div> <input type="range" id="featherSlider" min="0" max="5" value="1" step="0.5"> </div> <div class="control-group"> <div class="control-label"><span>☀️ 亮度</span><span class="control-value" id="brightnessVal">0</span></div> <input type="range" id="brightnessSlider" min="-30" max="30" value="0" step="1"> </div> <div class="section-label">📐 裁剪尺寸</div> <div class="size-presets" id="sizePresets"> <div class="size-preset active" data-width="295" data-height="413" data-name="一寸"><div>一寸</div><div class="size-dims">25×35mm</div></div> <div class="size-preset" data-width="413" data-height="579" data-name="二寸"><div>二寸</div><div class="size-dims">35×49mm</div></div> <div class="size-preset" data-width="260" data-height="378" data-name="小一寸"><div>小一寸</div><div class="size-dims">22×32mm</div></div> <div class="size-preset" data-width="390" data-height="567" data-name="小二寸"><div>小二寸</div><div class="size-dims">33×48mm</div></div> </div> <div class="action-row"> <button class="btn btn-primary btn-lg" id="processBtn" disabled>🎯 开始换底</button> <button class="btn btn-outline" id="resetBtn">🔄 重置</button> </div> </div> <!-- ========== 右侧面板 ========== --> <div class="card"> <div class="card-title">🖼️ 结果预览</div> <div class="result-container"> <div class="result-placeholder" id="resultPlaceholder"> <span class="icon">🖼️</span> <span>处理后的照片将显示在这里</span> </div> <img class="result-image" id="resultImage" style="display:none;" alt="处理结果"> </div> <div class="action-row"> <button class="btn btn-success btn-block" id="downloadBtn" disabled>💾 下载照片</button> </div> </div> </div> <div class="toast" id="toast"></div> <div class="footer">🔒 所有图片处理均在浏览器本地完成,不会上传到任何服务器</div> <script> (function() { // ==================== DOM引用 ==================== var uploadZone = document.getElementById('uploadZone'); var fileInput = document.getElementById('fileInput'); var processBtn = document.getElementById('processBtn'); var resetBtn = document.getElementById('resetBtn'); var downloadBtn = document.getElementById('downloadBtn'); var resultImage = document.getElementById('resultImage'); var resultPH = document.getElementById('resultPlaceholder'); var toastEl = document.getElementById('toast'); var colorPicker = document.getElementById('customColorPicker'); var hexInput = document.getElementById('hexInput'); var colorDot = document.getElementById('colorPreviewDot'); var tolSlider = document.getElementById('toleranceSlider'); var tolVal = document.getElementById('toleranceVal'); var feaSlider = document.getElementById('featherSlider'); var feaVal = document.getElementById('featherVal'); var briSlider = document.getElementById('brightnessSlider'); var briVal = document.getElementById('brightnessVal'); // ==================== 状态 ==================== var originalImage = null; var resultDataURL = null; var currentColor = '#FF0000'; var currentSize = { w: 295, h: 413, name: '一寸' }; var toastTimer = null; var autoTimer = null; var cachedMask = null; // ==================== Toast ==================== function toast(msg, type) { if (toastTimer) clearTimeout(toastTimer); toastEl.textContent = msg; toastEl.className = 'toast ' + (type || '') + ' show'; toastTimer = setTimeout(function() { toastEl.classList.remove('show'); }, 2000); } // ==================== 颜色工具 ==================== function hex3to6(s) { s = s.toUpperCase(); if (s.length === 3) { return s[0]+s[0]+s[1]+s[1]+s[2]+s[2]; } return s; } function isValidHex(s) { return /^[0-9a-fA-F]{3}$/.test(s) || /^[0-9a-fA-F]{6}$/.test(s); } function hexToRgb(hex) { hex = hex.replace('#', ''); if (hex.length === 3) hex = hex3to6(hex); return { r: parseInt(hex.substr(0,2), 16), g: parseInt(hex.substr(2,2), 16), b: parseInt(hex.substr(4,2), 16) }; } // 同步所有颜色UI function applyColor(hex6, source) { hex6 = hex6.toUpperCase(); if (!/^[0-9a-fA-F]{6}$/.test(hex6)) return; currentColor = '#' + hex6; // 取色器 if (source !== 'picker') { colorPicker.value = currentColor; } // 输入框 if (source !== 'input') { hexInput.value = hex6; hexInput.className = 'hex-input valid'; } // 预设圆点 if (source !== 'preset') { var all = document.querySelectorAll('.color-preset'); for (var i = 0; i < all.length; i++) { all[i].classList.remove('active'); } var m = document.querySelector('.color-preset[data-color="#' + hex6 + '"]'); if (m) m.classList.add('active'); } // 预览圆点 colorDot.style.background = currentColor; // 实时更新结果 scheduleAuto(); } // ==================== 颜色事件 ==================== document.getElementById('colorPresets').addEventListener('click', function(e) { var p = e.target.closest('.color-preset'); if (!p) return; var hex6 = p.getAttribute('data-color').replace('#', ''); var all = document.querySelectorAll('.color-preset'); for (var i = 0; i < all.length; i++) all[i].classList.remove('active'); p.classList.add('active'); applyColor(hex6, 'preset'); }); colorPicker.addEventListener('input', function() { var hex = colorPicker.value.replace('#', ''); if (isValidHex(hex)) { applyColor(hex3to6(hex), 'picker'); } }); hexInput.addEventListener('input', function() { var raw = hexInput.value.replace(/[^0-9a-fA-F]/g, '').slice(0, 6); hexInput.value = raw; if (raw.length === 6 && /^[0-9a-fA-F]{6}$/.test(raw)) { hexInput.className = 'hex-input valid'; applyColor(raw, 'input'); } else if (raw.length === 3 && /^[0-9a-fA-F]{3}$/.test(raw)) { hexInput.className = 'hex-input valid'; applyColor(hex3to6(raw), 'input'); } else if (raw.length === 0) { hexInput.className = 'hex-input'; } else if (/^[0-9a-fA-F]+$/.test(raw)) { hexInput.className = 'hex-input'; } else { hexInput.className = 'hex-input invalid'; } }); hexInput.addEventListener('blur', function() { var raw = hexInput.value; if (isValidHex(raw)) { var h6 = hex3to6(raw); hexInput.value = h6; hexInput.className = 'hex-input valid'; applyColor(h6, 'input'); } }); hexInput.addEventListener('paste', function(e) { e.preventDefault(); var t = (e.clipboardData || window.clipboardData).getData('text'); t = t.replace('#', '').replace(/[^0-9a-fA-F]/g, '').slice(0, 6); hexInput.value = t; if (isValidHex(t)) { hexInput.className = 'hex-input valid'; applyColor(hex3to6(t), 'input'); } }); // ==================== 尺寸 ==================== document.getElementById('sizePresets').addEventListener('click', function(e) { var p = e.target.closest('.size-preset'); if (!p) return; var all = document.querySelectorAll('.size-preset'); for (var i = 0; i < all.length; i++) all[i].classList.remove('active'); p.classList.add('active'); currentSize = { w: parseInt(p.getAttribute('data-width')), h: parseInt(p.getAttribute('data-height')), name: p.getAttribute('data-name') }; scheduleAuto(); }); // ==================== 滑块 ==================== tolSlider.addEventListener('input', function() { tolVal.textContent = tolSlider.value; cachedMask = null; scheduleAuto(); }); feaSlider.addEventListener('input', function() { feaVal.textContent = feaSlider.value; scheduleAuto(); }); briSlider.addEventListener('input', function() { briVal.textContent = briSlider.value; scheduleAuto(); }); // ==================== 防抖 ==================== function scheduleAuto() { if (!originalImage) return; if (autoTimer) clearTimeout(autoTimer); autoTimer = setTimeout(function() { doProcess(true); }, 200); } // ==================== 上传 ==================== uploadZone.addEventListener('click', function(e) { if (e.target.tagName === 'BUTTON' || e.target.tagName === 'INPUT') return; fileInput.click(); }); fileInput.addEventListener('change', function(e) { if (e.target.files && e.target.files[0]) { loadFile(e.target.files[0]); } }); uploadZone.addEventListener('dragover', function(e) { e.preventDefault(); uploadZone.classList.add('drag-over'); }); uploadZone.addEventListener('dragleave', function() { uploadZone.classList.remove('drag-over'); }); uploadZone.addEventListener('drop', function(e) { e.preventDefault(); uploadZone.classList.remove('drag-over'); if (e.dataTransfer.files && e.dataTransfer.files[0]) { loadFile(e.dataTransfer.files[0]); } }); function loadFile(file) { if (!file.type.match(/image\//)) { toast('请上传图片文件', 'error'); return; } var reader = new FileReader(); reader.onload = function(ev) { var img = new Image(); img.onload = function() { originalImage = img; cachedMask = null; showPreview(img); processBtn.disabled = false; toast('图片加载成功', 'success'); doProcess(false); }; img.onerror = function() { toast('图片加载失败', 'error'); }; img.src = ev.target.result; }; reader.readAsDataURL(file); } function showPreview(img) { // 清除旧预览 var old = uploadZone.querySelector('.image-container'); if (old) old.remove(); var iconEl = uploadZone.querySelector('.upload-icon'); var textEl = uploadZone.querySelector('.upload-text'); var hintEl = uploadZone.querySelector('.upload-hint'); uploadZone.classList.add('has-image'); if (iconEl) iconEl.style.display = 'none'; if (textEl) textEl.style.display = 'none'; if (hintEl) hintEl.style.display = 'none'; var container = document.createElement('div'); container.className = 'image-container'; var pImg = document.createElement('img'); pImg.className = 'preview-image'; pImg.src = img.src; pImg.alt = '原始照片'; var overlay = document.createElement('div'); overlay.className = 'image-actions-overlay'; var delBtn = document.createElement('button'); delBtn.className = 'btn-icon-sm'; delBtn.innerHTML = '✕'; delBtn.title = '移除图片'; delBtn.addEventListener('click', function(ev) { ev.stopPropagation(); resetAll(); }); overlay.appendChild(delBtn); container.appendChild(pImg); container.appendChild(overlay); uploadZone.appendChild(container); } // ==================== 核心算法 ==================== function doProcess(silent) { if (!originalImage) return; var tolerance = parseInt(tolSlider.value); var featherR = parseFloat(feaSlider.value); var brightness = parseInt(briSlider.value); var target = hexToRgb(currentColor); var srcW = originalImage.naturalWidth; var srcH = originalImage.naturalHeight; // 画到canvas var canvas = document.createElement('canvas'); canvas.width = srcW; canvas.height = srcH; var ctx = canvas.getContext('2d'); ctx.drawImage(originalImage, 0, 0); var imgData = ctx.getImageData(0, 0, srcW, srcH); var data = imgData.data; var w = srcW; var h = srcH; // 步骤1:建立遮罩(仅首次或容差改变时) if (cachedMask === null) { // 采样边缘 var samples = []; var step = Math.max(1, Math.floor(Math.min(w, h) / 30)); var x, y; for (x = 0; x < w; x += step) { samples.push(getPixel(data, w, x, 0)); samples.push(getPixel(data, w, x, h - 1)); } for (y = 0; y < h; y += step) { samples.push(getPixel(data, w, 0, y)); samples.push(getPixel(data, w, w - 1, y)); } // 中位数 samples.sort(function(a, b) { return (a.r + a.g + a.b) - (b.r + b.g + b.b); }); var mid = samples[Math.floor(samples.length / 2)]; var bgR = mid.r; var bgG = mid.g; var bgB = mid.b; // 颜色距离 var mask = new Uint8Array(w * h); var tolSq = tolerance * tolerance; var i; for (i = 0; i < w * h; i++) { var pi = i * 4; var dr = data[pi] - bgR; var dg = data[pi + 1] - bgG; var db = data[pi + 2] - bgB; if (dr * dr + dg * dg + db * db <= tolSq) { mask[i] = 1; } } // Flood fill 从边缘连通 var visited = new Uint8Array(w * h); var queue = []; var head = 0; var x2, y2; for (x2 = 0; x2 < w; x2++) { if (mask[x2] === 1 && !visited[x2]) { visited[x2] = 1; queue.push(x2); } var bIdx = (h - 1) * w + x2; if (mask[bIdx] === 1 && !visited[bIdx]) { visited[bIdx] = 1; queue.push(bIdx); } } for (y2 = 0; y2 < h; y2++) { var lIdx = y2 * w; if (mask[lIdx] === 1 && !visited[lIdx]) { visited[lIdx] = 1; queue.push(lIdx); } var rIdx = y2 * w + (w - 1); if (mask[rIdx] === 1 && !visited[rIdx]) { visited[rIdx] = 1; queue.push(rIdx); } } while (head < queue.length) { var qIdx = queue[head++]; var qx = qIdx % w; var qy = Math.floor(qIdx / w); var nb = []; if (qx > 0) nb.push(qIdx - 1); if (qx < w - 1) nb.push(qIdx + 1); if (qy > 0) nb.push(qIdx - w); if (qy < h - 1) nb.push(qIdx + w); for (var ni = 0; ni < nb.length; ni++) { var nIdx = nb[ni]; if (mask[nIdx] === 1 && !visited[nIdx]) { visited[nIdx] = 1; queue.push(nIdx); } } } cachedMask = visited; } // 步骤2:从原始图重新着色 var origCanvas = document.createElement('canvas'); origCanvas.width = w; origCanvas.height = h; var origCtx = origCanvas.getContext('2d'); origCtx.drawImage(originalImage, 0, 0); var origData = origCtx.getImageData(0, 0, w, h).data; var work = new Uint8ClampedArray(origData.length); var k; for (k = 0; k < origData.length; k++) { work[k] = origData[k]; } var featherPx = Math.round(featherR); var j; for (j = 0; j < work.length; j += 4) { var idx = j / 4; if (cachedMask[idx] === 1) { var alpha = 1; if (featherPx > 0) { alpha = getFeather(cachedMask, idx, w, h, featherPx); } work[j] = Math.round(work[j] * (1 - alpha) + target.r * alpha); work[j + 1] = Math.round(work[j + 1] * (1 - alpha) + target.g * alpha); work[j + 2] = Math.round(work[j + 2] * (1 - alpha) + target.b * alpha); } } // 亮度 if (brightness !== 0) { var bj; for (bj = 0; bj < work.length; bj += 4) { work[bj] = clamp(work[bj] + brightness, 0, 255); work[bj + 1] = clamp(work[bj + 1] + brightness, 0, 255); work[bj + 2] = clamp(work[bj + 2] + brightness, 0, 255); } } var resultID = new ImageData(work, w, h); var workCanvas = document.createElement('canvas'); workCanvas.width = w; workCanvas.height = h; var workCtx = workCanvas.getContext('2d'); workCtx.putImageData(resultID, 0, 0); // 裁剪 var finalCanvas = cropCanvas(workCanvas, currentSize.w, currentSize.h); resultDataURL = finalCanvas.toDataURL('image/png'); resultImage.src = resultDataURL; resultImage.style.display = 'block'; resultPH.style.display = 'none'; downloadBtn.disabled = false; if (!silent) { toast('换底完成!', 'success'); } } function getPixel(data, w, x, y) { var i = (y * w + x) * 4; return { r: data[i], g: data[i + 1], b: data[i + 2] }; } function getFeather(mask, idx, w, h, radius) { var y = Math.floor(idx / w); var x = idx % w; var fg = 0; var total = 0; var dy, dx; for (dy = -radius; dy <= radius; dy++) { for (dx = -radius; dx <= radius; dx++) { var nx = x + dx; var ny = y + dy; if (nx >= 0 && nx < w && ny >= 0 && ny < h) { total++; if (mask[ny * w + nx] === 0) fg++; } } } if (total === 0) return 1; var ratio = fg / total; if (ratio > 0.6) return 0; if (ratio > 0.3) return 0.5; return 1; } function cropCanvas(srcCanvas, tw, th) { var sw = srcCanvas.width; var sh = srcCanvas.height; var sa = sw / sh; var ta = tw / th; var cw, ch, ox, oy; if (sa > ta) { ch = sh; cw = Math.round(sh * ta); ox = Math.round((sw - cw) / 2); oy = 0; } else { cw = sw; ch = Math.round(sw / ta); ox = 0; oy = Math.round((sh - ch) / 2); } var fc = document.createElement('canvas'); fc.width = tw; fc.height = th; var fctx = fc.getContext('2d'); fctx.imageSmoothingEnabled = true; fctx.imageSmoothingQuality = 'high'; fctx.drawImage(srcCanvas, ox, oy, cw, ch, 0, 0, tw, th); return fc; } function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } // ==================== 按钮 ==================== processBtn.addEventListener('click', function() { processBtn.disabled = true; processBtn.innerHTML = '<span class="spinner"></span> 处理中...'; cachedMask = null; setTimeout(function() { try { doProcess(false); } catch(err) { console.error(err); toast('处理出错: ' + err.message, 'error'); } processBtn.disabled = false; processBtn.innerHTML = '🎯 开始换底'; }, 30); }); resetBtn.addEventListener('click', resetAll); downloadBtn.addEventListener('click', function() { if (!resultDataURL) return; var a = document.createElement('a'); a.download = '证件照_' + currentSize.name + '_' + Date.now() + '.png'; a.href = resultDataURL; document.body.appendChild(a); a.click(); document.body.removeChild(a); toast('下载中', 'success'); }); function resetAll() { originalImage = null; resultDataURL = null; cachedMask = null; if (autoTimer) clearTimeout(autoTimer); resultImage.style.display = 'none'; resultImage.src = ''; resultPH.style.display = ''; downloadBtn.disabled = true; processBtn.disabled = true; processBtn.innerHTML = '🎯 开始换底'; uploadZone.classList.remove('has-image'); var old = uploadZone.querySelector('.image-container'); if (old) old.remove(); var iconEl = uploadZone.querySelector('.upload-icon'); var textEl = uploadZone.querySelector('.upload-text'); var hintEl = uploadZone.querySelector('.upload-hint'); if (iconEl) iconEl.style.display = ''; if (textEl) textEl.style.display = ''; if (hintEl) hintEl.style.display = ''; fileInput.value = ''; tolSlider.value = 50; tolVal.textContent = '50'; feaSlider.value = 1; feaVal.textContent = '1'; briSlider.value = 0; briVal.textContent = '0'; currentColor = '#FF0000'; colorPicker.value = '#FF0000'; hexInput.value = 'FF0000'; hexInput.className = 'hex-input valid'; colorDot.style.background = '#FF0000'; var all = document.querySelectorAll('.color-preset'); for (var i = 0; i < all.length; i++) all[i].classList.remove('active'); var red = document.querySelector('.color-preset[data-color="#FF0000"]'); if (red) red.classList.add('active'); var allS = document.querySelectorAll('.size-preset'); for (var j = 0; j < allS.length; j++) allS[j].classList.remove('active'); var yc = document.querySelector('.size-preset[data-name="一寸"]'); if (yc) yc.classList.add('active'); currentSize = { w: 295, h: 413, name: '一寸' }; } // ==================== 快捷键 ==================== document.addEventListener('keydown', function(e) { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); if (!processBtn.disabled) processBtn.click(); } if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); if (!downloadBtn.disabled) downloadBtn.click(); } }); console.log('证件照换底工具已就绪 - 离线处理 - 实时预览'); })(); </script> </body> </html> 2 个帖子 - 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 歌词嵌入 · LRCLib</title> <style> :root { --bg: #08080f; --surface: #111118; --surface2: #181820; --border: #222230; --text: #e0e0e8; --text2: #8888a0; --accent: #a78bfa; --accent2: #7c5cfc; --green: #34d399; --gold: #fbbf24; --radius: 16px; --radius-sm: 10px; --radius-xs: 8px; } * { margin: 0; padding: 0; box-sizing: border-box; } body { background: var(--bg); font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif; min-height: 100vh; display: flex; justify-content: center; padding: 28px 16px; color: var(--text); } .container { width: 100%; max-width: 960px; display: flex; flex-direction: column; gap: 18px; } .header { text-align: center; padding: 8px 0 4px; } .header h1 { font-size: 1.7rem; font-weight: 700; letter-spacing: -0.4px; background: linear-gradient(135deg, #a78bfa, #34d399); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .header .sub { font-size: 0.82rem; color: var(--text2); margin-top: 2px; } .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 22px; } .card-label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 1.5px; color: var(--text2); margin-bottom: 12px; font-weight: 600; } .drop-wrapper { position: relative; } .drop-wrapper input[type="file"] { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; z-index: 2; } .drop-zone { background: var(--surface2); border: 2px dashed #2a2a3e; border-radius: var(--radius); padding: 32px 20px; text-align: center; transition: all 0.2s; pointer-events: none; } .drop-wrapper.drag-over .drop-zone { border-color: var(--accent); background: #1a1a28; box-shadow: 0 0 0 6px rgba(124, 92, 252, 0.15); } .drop-wrapper:hover .drop-zone { border-color: var(--accent); background: #1a1a28; box-shadow: 0 0 0 6px rgba(124, 92, 252, 0.06); } .drop-zone .dz-icon { font-size: 2.4rem; margin-bottom: 8px; opacity: 0.8; } .drop-zone .dz-title { font-weight: 600; font-size: 0.95rem; } .drop-zone .dz-hint { font-size: 0.78rem; color: var(--text2); margin-top: 4px; } .file-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; } .chip { display: inline-flex; align-items: center; gap: 6px; background: var(--surface2); border: 1px solid var(--border); padding: 6px 12px; border-radius: 20px; font-size: 0.8rem; font-family: 'SF Mono', 'Consolas', monospace; color: #c0c0d0; } .chip .chip-tag { font-size: 0.64rem; background: #1e1e30; color: var(--accent); padding: 2px 7px; border-radius: 10px; font-weight: 500; } .chip .chip-del { cursor: pointer; color: #666; font-weight: 700; font-size: 1rem; line-height: 1; margin-left: 2px; transition: color 0.15s; } .chip .chip-del:hover { color: #f87171; } .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } .row.mt { margin-top: 12px; } .input { flex: 1; min-width: 180px; padding: 11px 16px; border-radius: var(--radius-xs); border: 1px solid var(--border); background: var(--surface2); color: var(--text); font-size: 0.9rem; outline: none; font-family: inherit; transition: border-color 0.2s; } .input:focus { border-color: var(--accent); } .input::placeholder { color: #555; } .btn { padding: 10px 20px; border-radius: 24px; border: 1px solid var(--border); background: var(--surface2); color: var(--text); cursor: pointer; font-size: 0.85rem; font-weight: 600; transition: all 0.2s; white-space: nowrap; display: inline-flex; align-items: center; gap: 5px; font-family: inherit; letter-spacing: 0.2px; } .btn:hover { background: #222238; border-color: #444; } .btn-primary { background: var(--accent2); border-color: var(--accent2); color: #fff; box-shadow: 0 4px 18px rgba(124, 92, 252, 0.25); } .btn-primary:hover { background: #8f6fff; border-color: #8f6fff; box-shadow: 0 6px 24px rgba(124, 92, 252, 0.35); } .btn-sm { padding: 6px 14px; font-size: 0.76rem; border-radius: 18px; } .btn-xs { padding: 4px 10px; font-size: 0.7rem; border-radius: 14px; } .btn:disabled { opacity: 0.35; cursor: not-allowed; pointer-events: none; } .hint { font-size: 0.72rem; color: var(--text2); margin-top: 6px; font-style: italic; } .results-box { display: none; max-height: 280px; overflow-y: auto; margin-top: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface2); } .results-box.open { display: block; } .result-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; cursor: pointer; transition: background 0.12s; gap: 10px; flex-wrap: wrap; } .result-row:hover { background: #1e1e2c; } .result-row+.result-row { border-top: 1px solid rgba(255, 255, 255, 0.03); } .result-row.selected { background: #1a1830; border-left: 3px solid var(--accent); } .result-info { flex: 1; min-width: 0; } .result-info .rtrack { font-weight: 600; font-size: 0.88rem; } .result-info .rartist { font-size: 0.76rem; color: var(--text2); } .result-meta { font-size: 0.7rem; color: #555; white-space: nowrap; } .empty { text-align: center; color: var(--text2); padding: 28px; font-size: 0.85rem; } .spinner-wrap { text-align: center; padding: 28px; color: var(--text2); display: flex; align-items: center; justify-content: center; gap: 8px; } .spinner { width: 16px; height: 16px; 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; margin-top: 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; } .lyrics-panel.open { display: block; } .lyrics-top { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px 16px; background: #14141e; border-bottom: 1px solid var(--border); } .lyrics-top .ltitle { font-weight: 700; font-size: 1rem; } .lyrics-top .lartist { color: var(--accent); font-size: 0.82rem; } .lyrics-top .lalbum { color: var(--text2); font-size: 0.76rem; } .tabs { display: flex; gap: 3px; flex-wrap: wrap; } .tab { padding: 5px 12px; border-radius: 16px; border: 1px solid var(--border); background: transparent; color: var(--text2); cursor: pointer; font-size: 0.72rem; font-weight: 500; transition: 0.2s; font-family: inherit; } .tab.on { background: var(--accent2); border-color: var(--accent2); color: #fff; } .lyrics-content { padding: 16px; max-height: 340px; overflow-y: auto; background: #0c0c16; font-family: 'SF Mono', 'Fira Code', 'Consolas', 'PingFang SC', monospace; font-size: 0.8rem; line-height: 1.75; white-space: pre-wrap; color: #c0c0d0; } .lyrics-content .hl-tag { color: var(--accent); font-weight: 600; } .lyrics-content .hl-idx { color: #666; } .lyrics-content .hl-time { color: var(--green); } .lyrics-content .hl-section { color: var(--gold); } .lyrics-content .hl-style { color: #60a5fa; } .check-row { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; padding: 10px 0; font-size: 0.76rem; color: var(--text2); } .check-row label { display: flex; align-items: center; gap: 5px; cursor: pointer; user-select: none; } .check-row input[type="checkbox"] { accent-color: var(--accent2); width: 15px; height: 15px; cursor: pointer; } .action-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; padding-top: 12px; border-top: 1px solid var(--border); } .tag-chips { display: flex; gap: 4px; flex-wrap: wrap; } .tag-chip { padding: 4px 10px; border-radius: 14px; border: 1px solid var(--border); background: transparent; color: var(--text2); cursor: pointer; font-size: 0.68rem; font-weight: 500; transition: 0.2s; user-select: none; font-family: inherit; } .tag-chip.on { background: var(--accent2); border-color: var(--accent2); color: #fff; } .toast { position: fixed; top: 16px; left: 50%; transform: translateX(-50%); padding: 10px 22px; border-radius: 24px; font-weight: 600; font-size: 0.82rem; z-index: 999; opacity: 0; pointer-events: none; transition: opacity 0.3s; letter-spacing: 0.2px; } .toast.show { opacity: 1; } .toast.ok { background: #065f46; color: #d1fae5; box-shadow: 0 8px 28px rgba(5, 150, 105, 0.3); } .toast.err { background: #7f1d1d; color: #fecaca; box-shadow: 0 8px 28px rgba(220, 38, 38, 0.3); } .footer-note { text-align: center; font-size: 0.7rem; color: #555; } ::-webkit-scrollbar { width: 4px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; } @media (max-width: 640px) { .row { flex-direction: column; align-items: stretch; } .btn { justify-content: center; } .action-row { flex-direction: column; align-items: stretch; } } </style> </head> <body> <div class="container"> <div class="header"> <h1>🎵 FLAC 歌词嵌入工具</h1> <div class="sub">LRCLib 歌词搜索 · Vorbis Comment 标签写入 · 元数据覆盖</div> </div> <div class="card"> <div class="card-label">📂 选择 FLAC 文件</div> <div class="drop-wrapper" id="dropWrapper"> <input type="file" id="fileInput" accept=".flac,audio/flac" multiple> <div class="drop-zone"> <div class="dz-icon">🎶</div> <div class="dz-title">点击选择或拖拽 FLAC 文件</div> <div class="dz-hint">支持批量 · 自动读取 TITLE / ARTIST / ALBUM 标签</div> </div> </div> <div class="file-chips" id="fileChips"></div> <div class="row mt"> <button class="btn btn-sm" id="clearFilesBtn" disabled>🗑 清空</button> <span style="font-size:0.76rem;color:var(--text2);" id="fileStatus">等待添加 FLAC…</span> </div> </div> <div class="card"> <div class="card-label">🔍 搜索歌词</div> <div class="row"> <input class="input" id="searchInput" placeholder="歌曲名 或「歌曲名 - 歌手名」" autocomplete="off"> <button class="btn btn-primary" id="searchBtn">搜索</button> <button class="btn btn-sm" id="directBtn">🎯 精确获取</button> </div> <div class="hint" id="autoFillHint"></div> <div class="results-box" id="resultsBox"> <div id="resultsList"></div> </div> </div> <div class="card" id="lyricsCard" style="display:none;"> <div class="lyrics-panel open" id="lyricsPreview"> <div class="lyrics-top"> <div> <span class="ltitle" id="songName">—</span> <span style="margin:0 5px;color:#555;">·</span> <span class="lartist" id="songArtist">—</span> <span style="margin:0 5px;color:#555;">·</span> <span class="lalbum" id="songAlbum">—</span> </div> <div class="tabs"> <button class="tab on" data-fmt="lrc">LRC</button> <button class="tab" data-fmt="srt">SRT</button> <button class="tab" data-fmt="ass">ASS</button> <button class="tab" data-fmt="vtt">VTT</button> </div> </div> <div class="lyrics-content" id="lyricsContent"></div> </div> <div class="check-row"> <span>🔧 写入时覆盖:</span> <label><input type="checkbox" id="ovTitle" checked> TITLE</label> <label><input type="checkbox" id="ovArtist" checked> ARTIST</label> <label><input type="checkbox" id="ovAlbum" checked> ALBUM</label> <span style="color:#555;font-size:0.68rem;">用 API 返回信息覆盖 FLAC 标签</span> </div> <div class="action-row"> <span style="font-size:0.72rem;color:var(--text2);font-weight:600;">歌词标签</span> <div class="tag-chips" id="tagChips"> <span class="tag-chip on" data-tag="LYRICS">LYRICS</span> <span class="tag-chip" data-tag="UNSYNCEDLYRICS">UNSYNCEDLYRICS</span> <span class="tag-chip" data-tag="LYRICS_LRC">LYRICS_LRC</span> <span class="tag-chip" data-tag="LYRICS_SRT">LYRICS_SRT</span> <span class="tag-chip" data-tag="LYRICS_ASS">LYRICS_ASS</span> <span class="tag-chip" data-tag="LYRICS_VTT">LYRICS_VTT</span> </div> <span style="flex:1;"></span> <button class="btn btn-primary" id="embedBtn" disabled>💾 写入并下载</button> </div> </div> <div class="footer-note">浏览器安全限制:通过下载生成新文件,原文件不被修改</div> </div> <div class="toast" id="toast"></div> <script> (function() { var DW = document.getElementById('dropWrapper'); var FI = document.getElementById('fileInput'); var FC = document.getElementById('fileChips'); var CFB = document.getElementById('clearFilesBtn'); var FST = document.getElementById('fileStatus'); var SI = document.getElementById('searchInput'); var SB = document.getElementById('searchBtn'); var DB = document.getElementById('directBtn'); var AFH = document.getElementById('autoFillHint'); var RB = document.getElementById('resultsBox'); var RL = document.getElementById('resultsList'); var LC = document.getElementById('lyricsCard'); var LCT = document.getElementById('lyricsContent'); var SN = document.getElementById('songName'); var SA = document.getElementById('songArtist'); var SAL = document.getElementById('songAlbum'); var EB = document.getElementById('embedBtn'); var OVT = document.getElementById('ovTitle'); var OVA = document.getElementById('ovArtist'); var OVAL = document.getElementById('ovAlbum'); var TO = document.getElementById('toast'); var TABS = document.querySelectorAll('.tab'); var TCHIPS = document.querySelectorAll('.tag-chip'); var files = []; var song = null; var fmt = 'lrc'; var tag = 'LYRICS'; var results = []; var selectedIdx = -1; var tt; function toast(m, e) { clearTimeout(tt); TO.textContent = m; TO.className = 'toast show ' + (e ? 'err' : 'ok'); tt = setTimeout(function() { TO.className = 'toast'; }, 2200); } function esc(s) { var d = document.createElement('div'); d.appendChild(document.createTextNode(s)); return d.innerHTML; } function p2(n) { return String(n).padStart(2, '0'); } function p3(n) { return String(n).padStart(3, '0'); } function readMeta(file, cb) { var r = new FileReader(); r.onload = function(e) { try { var b = e.target.result; var v = new DataView(b); if (b.byteLength < 4) return cb({ t: null, a: null, c: null, al: null }); if (String.fromCharCode(v.getUint8(0), v.getUint8(1), v.getUint8(2), v.getUint8(3)) !== 'fLaC') return cb({ t: null, a: null, c: null, al: null }); var o = 4, lb = false, t = null, a = null, c = null, al = null; while (o < b.byteLength && !lb) { if (o + 4 > b.byteLength) break; var h = v.getUint8(o); lb = (h & 0x80) !== 0; var bt = h & 0x7F; var bs = (v.getUint8(o + 1) << 16) | (v.getUint8(o + 2) << 8) | v.getUint8(o + 3); o += 4; if (bt === 4 && o + bs <= b.byteLength) { var bl = new Uint8Array(b, o, bs); var dec = new TextDecoder('utf-8'); if (bl.length >= 4) { var vl = bl[0] | (bl[1] << 8) | (bl[2] << 16) | (bl[3] << 24); var p = 4 + vl; if (p + 4 <= bl.length) { var nc = bl[p] | (bl[p + 1] << 8) | (bl[p + 2] << 16) | (bl[p + 3] << 24); p += 4; for (var i = 0; i < nc && p + 4 <= bl.length; i++) { var cl = bl[p] | (bl[p + 1] << 8) | (bl[p + 2] << 16) | (bl[p + 3] << 24); p += 4; if (p + cl > bl.length) break; var cs = dec.decode(bl.slice(p, p + cl)); p += cl; var ei = cs.indexOf('='); if (ei > 0) { var k = cs.substring(0, ei).toUpperCase().trim(); var val = cs.substring(ei + 1).trim(); if (k === 'TITLE' && !t) t = val; if (k === 'ARTIST' && !a) a = val; if (k === 'COMPOSER' && !c) c = val; if (k === 'ALBUM' && !al) al = val; } } } } break; } o += bs; } cb({ t: t, a: a, c: c, al: al }); } catch (x) { cb({ t: null, a: null, c: null, al: null }); } }; r.onerror = function() { cb({ t: null, a: null, c: null, al: null }); }; r.readAsArrayBuffer(file); } function autoFill() { if (files.length === 0) { AFH.textContent = ''; return; } var t = files[0].t || ''; var a = files[0].a || files[0].c || ''; if (t) { var ft = t; if (a) ft += ' - ' + a; SI.value = ft; AFH.textContent = '📋 已从 FLAC 标签自动填充:' + ft; } else { AFH.textContent = '⚠️ 未找到 TITLE 标签,请手动输入'; } } function updateFiles() { FC.innerHTML = ''; if (files.length === 0) { CFB.disabled = true; FST.textContent = '等待添加 FLAC…'; EB.disabled = true; AFH.textContent = ''; } else { CFB.disabled = false; FST.textContent = files.length + ' 个 FLAC 文件'; if (song) EB.disabled = false; for (var i = 0; i < files.length; i++) { var f = files[i]; var el = document.createElement('span'); el.className = 'chip'; var h = '🎵 ' + esc(f.file.name); if (f.t) h += ' <span class="chip-tag">' + esc(f.t) + '</span>'; h += ' <span class="chip-del" data-idx="' + i + '">×</span>'; el.innerHTML = h; FC.appendChild(el); } var dels = FC.querySelectorAll('.chip-del'); for (var d = 0; d < dels.length; d++) { dels[d].onclick = function(e) { e.stopPropagation(); var idx = parseInt(this.getAttribute('data-idx'), 10); files.splice(idx, 1); updateFiles(); autoFill(); }; } } } function addFiles(fileList) { var only = []; for (var i = 0; i < fileList.length; i++) { if (!fileList[i].name.toLowerCase().endsWith('.flac')) continue; var dup = false; for (var j = 0; j < files.length; j++) { if (files[j].file.name === fileList[i].name && files[j].file.size === fileList[i].size && files[j].file.lastModified === fileList[i].lastModified) { dup = true; break; } } if (!dup) only.push(fileList[i]); } if (only.length === 0) { if (fileList.length > 0) toast('请选择 .flac 文件', true); return; } FST.textContent = '🔍 读取元数据…'; var done = 0; var total = only.length; function handleOne(f, meta) { files.push({ file: f, t: meta.t, a: meta.a, c: meta.c, al: meta.al }); done++; if (done >= total) { updateFiles(); autoFill(); toast('✅ 已添加 ' + total + ' 个 FLAC 文件'); if (files.length > 0 && SI.value.trim()) doSearch(SI.value); } } for (var k = 0; k < only.length; k++) { (function(fileRef) { readMeta(fileRef, function(meta) { handleOne(fileRef, meta); }); })(only[k]); } } function handleFileSelect(fileList) { if (fileList && fileList.length) { addFiles(fileList); } } FI.addEventListener('change', function(e) { handleFileSelect(e.target.files); FI.value = ''; }); DW.addEventListener('dragover', function(e) { e.preventDefault(); e.stopPropagation(); DW.classList.add('drag-over'); }); DW.addEventListener('dragleave', function(e) { e.preventDefault(); e.stopPropagation(); DW.classList.remove('drag-over'); }); DW.addEventListener('drop', function(e) { e.preventDefault(); e.stopPropagation(); DW.classList.remove('drag-over'); if (e.dataTransfer.files && e.dataTransfer.files.length) { handleFileSelect(e.dataTransfer.files); } }); CFB.addEventListener('click', function() { files = []; updateFiles(); autoFill(); toast('🧹 已清空'); }); function apiSearch(q) { return fetch('https://lrclib.net/api/search?q=' + encodeURIComponent(q)).then(function(r) { if (!r.ok) throw new Error('搜索失败'); return r.json(); }); } function apiGet(t, a) { return fetch('https://lrclib.net/api/get?track_name=' + encodeURIComponent(t) + '&artist_name=' + encodeURIComponent(a)).then(function(r) { if (!r.ok) throw new Error('获取失败'); return r.text(); }).then(function(t) { if (!t) throw new Error('未找到'); return JSON.parse(t); }); } function parseLrc(sl) { var items = []; if (!sl) return items; var re = /\[(\d{2}):(\d{2})\.(\d{2,3})\]\s*(.*)/g, m; while ((m = re.exec(sl)) !== null) { var ms = m[3].length === 2 ? parseInt(m[3], 10) * 10 : parseInt(m[3], 10); items.push({ tm: parseInt(m[1]) * 60000 + parseInt(m[2]) * 1000 + ms, tx: (m[4] || '').trim() }); } return items; } function msLrc(ms) { var mn = Math.floor(ms / 60000); return '[' + p2(mn) + ':' + p2(Math.floor((ms % 60000) / 1000)) + '.' + p2(Math.floor((ms % 1000) / 10)) + ']'; } function msSrt(ms) { var h = Math.floor(ms / 3600000); return p2(h) + ':' + p2(Math.floor((ms % 3600000) / 60000)) + ':' + p2(Math.floor((ms % 60000) / 1000)) + ',' + p3(ms % 1000); } function msAss(ms) { var h = Math.floor(ms / 3600000); return h + ':' + p2(Math.floor((ms % 3600000) / 60000)) + ':' + p2(Math.floor((ms % 60000) / 1000)) + '.' + p2(Math.floor((ms % 1000) / 10)); } function msVtt(ms) { var h = Math.floor(ms / 3600000); return p2(h) + ':' + p2(Math.floor((ms % 3600000) / 60000)) + ':' + p2(Math.floor((ms % 60000) / 1000)) + '.' + p3(ms % 1000); } function genLRC(s) { var it = parseLrc(s.syncedLyrics); var l = []; l.push('[ti:' + (s.trackName || '') + ']'); l.push('[ar:' + (s.artistName || '') + ']'); if (s.albumName) l.push('[al:' + s.albumName + ']'); if (s.duration) { var m = Math.floor(s.duration / 60); l.push('[length:' + p2(m) + ':' + p2(Math.floor(s.duration % 60)) + ']'); } l.push(''); if (it.length) { for (var i = 0; i < it.length; i++) l.push(msLrc(it[i].tm) + (it[i].tx || '♪')); } else if ( s.plainLyrics) { var pl = s.plainLyrics.split('\n'); for (var j = 0; j < pl.length; j++) l.push(pl[j] .trim()); } return l.join('\n'); } function genSRT(s) { var it = parseLrc(s.syncedLyrics); if (!it.length) return s.plainLyrics ? '1\n00:00:00,000 --> 00:03:00,000\n' + s.plainLyrics.trim() + '\n' : ''; var l = []; for (var i = 0; i < it.length; i++) { var nx = it[i + 1], em = nx ? nx.tm : it[i].tm + 3000; l.push(String(i + 1)); l.push(msSrt(it[i].tm) + ' --> ' + msSrt(em)); l.push(it[i].tx || '♪'); l.push(''); } return l.join('\n').trim(); } function genASS(s) { var it = parseLrc(s.syncedLyrics); var l = []; l.push('[Script Info]'); l.push('Title: ' + (s.trackName || 'Unknown')); l.push('Original Script: ' + (s.artistName || 'Unknown')); l.push('ScriptType: v4.00+'); l.push('Collisions: Normal'); l.push('PlayDepth: 0'); l.push(''); l.push('[V4+ Styles]'); l.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'); l.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'); l.push(''); l.push('[Events]'); l.push('Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text'); if (it.length) { for (var i = 0; i < it.length; i++) { var nx = it[i + 1], em = nx ? nx.tm : it[i].tm + 3000; l.push('Dialogue: 0,' + msAss(it[i].tm) + ',' + msAss(em) + ',Default,,0,0,0,,' + (it[i].tx || '♪').replace(/\n/g, '\\N')); } } else if (s.plainLyrics) { var pl = s.plainLyrics.split('\n').filter(function(x) { return x.trim(); }); var dur = (s.duration || 180) * 1000; var each = Math.floor(dur / Math.max(pl.length, 1)); for (var j = 0; j < pl.length; j++) l.push('Dialogue: 0,' + msAss(j * each) + ',' + msAss(j * each + each) + ',Default,,0,0,0,,' + pl[j].trim().replace(/\n/g, '\\N')); } return l.join('\n'); } function genVTT(s) { var it = parseLrc(s.syncedLyrics); var l = []; l.push('WEBVTT'); l.push(''); if (it.length) { for (var i = 0; i < it.length; i++) { var nx = it[i + 1], em = nx ? nx.tm : it[i].tm + 3000; l.push(msVtt(it[i].tm) + ' --> ' + msVtt(em)); l.push(it[i].tx || '♪'); l.push(''); } } else if (s.plainLyrics) { var pl = s.plainLyrics.split('\n').filter(function(x) { return x.trim(); }); var dur = (s.duration || 180) * 1000; var each = Math.floor(dur / Math.max(pl.length, 1)); for (var j = 0; j < pl.length; j++) { l.push(msVtt(j * each) + ' --> ' + msVtt(j * each + each)); l.push(pl[j].trim()); l.push(''); } } return l.join('\n').trim(); } function getFmt(f, s) { if (!s) return ''; if (f === 'lrc') return genLRC(s); if (f === 'srt') return genSRT(s); if (f === 'ass') return genASS(s); if (f === 'vtt') return genVTT(s); return ''; } function hlHtml(f, s) { var raw = getFmt(f, s); if (!raw) return '<span style="color:#555;">暂无内容</span>'; var e = esc(raw); if (f === 'lrc') { e = e.replace(/^(\[ti:.*\]|\[ar:.*\]|\[al:.*\]|\[length:.*\])$/gm, '<span class="hl-tag">$1</span>'); e = e.replace(/^(\[\d{2}:\d{2}\.\d{2}\])/gm, '<span class="hl-tag">$1</span>'); } else if (f === 'srt') { e = e.replace(/^(\d+)$/gm, '<span class="hl-idx">$1</span>'); e = e.replace(/^(\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3})$/gm, '<span class="hl-time">$1</span>'); } else if (f === 'ass') { e = e.replace(/^(\[.*\])$/gm, '<span class="hl-section">$1</span>'); e = e.replace(/^(Style:.*)$/gm, '<span class="hl-style">$1</span>'); e = e.replace(/^(Format:.*)$/gm, '<span class="hl-style">$1</span>'); } else if (f === 'vtt') { e = e .replace(/^(WEBVTT)$/gm, '<span class="hl-section">$1</span>'); e = e.replace(/^(\d{2}:\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}:\d{2}\.\d{3})$/gm, '<span class="hl-time">$1</span>'); } return e; } function renderSong(s) { song = s; SN.textContent = s.trackName || '未知'; SA.textContent = s.artistName || '未知'; SAL.textContent = s.albumName ? '💿 ' + s.albumName : ''; LC.style.display = 'block'; if (files.length > 0) EB.disabled = false; updateLyrics(); LC.scrollIntoView({ behavior: 'smooth', block: 'center' }); } function updateLyrics() { LCT.innerHTML = hlHtml(fmt, song); } for (var t = 0; t < TABS.length; t++) { TABS[t].addEventListener('click', function() { for (var i = 0; i < TABS.length; i++) TABS[i].classList.remove('on'); this.classList.add('on'); fmt = this.getAttribute('data-fmt'); updateLyrics(); }); } for (var c = 0; c < TCHIPS.length; c++) { TCHIPS[c].addEventListener('click', function() { for (var i = 0; i < TCHIPS.length; i++) TCHIPS[i].classList.remove('on'); this.classList.add('on'); tag = this.getAttribute('data-tag'); }); } function doSearch(q) { if (!q || !q.trim()) { toast('请输入关键词', true); return; } results = []; selectedIdx = -1; RL.innerHTML = '<div class="spinner-wrap"><span class="spinner"></span>搜索中…</div>'; RB.classList.add('open'); apiSearch(q.trim()).then(function(rs) { results = rs; if (!rs.length) { RL.innerHTML = '<div class="empty">😕 未找到匹配的歌曲</div>'; return; } var h = ''; for (var i = 0; i < rs.length; i++) { var r = rs[i]; h += '<div class="result-row" data-idx="' + i + '">'; h += '<div class="result-info"><div class="rtrack">' + esc(r.trackName || r.name || '未知') + '</div><div class="rartist">' + esc(r.artistName || '未知') + '</div></div>'; h += '<div class="result-meta">' + fdur(r.duration) + '</div>'; h += '<button class="btn btn-xs pick-btn" data-idx="' + i + '">选择</button>'; h += '</div>'; } RL.innerHTML = h; bindResults(); }).catch(function(err) { RL.innerHTML = '<div class="empty">❌ ' + esc(err.message) + '</div>'; }); } function bindResults() { var rows = RL.querySelectorAll('.result-row'); for (var j = 0; j < rows.length; j++) { (function(idx) { rows[j].addEventListener('click', function(e) { if (e.target.closest('.pick-btn')) return; loadResult(idx); }); })(j); } var btns = RL.querySelectorAll('.pick-btn'); for (var k = 0; k < btns.length; k++) { (function(idx) { btns[k].addEventListener('click', function(e) { e.stopPropagation(); loadResult(idx); }); })(k); } } function loadResult(idx) { var s = results[idx]; if (!s) return; selectedIdx = idx; var tn = s.trackName || s.name; var an = s.artistName || ''; var rows = RL.querySelectorAll('.result-row'); for (var i = 0; i < rows.length; i++) { rows[i].classList.remove('selected'); rows[i].style.opacity = '0.4'; } var tgt = RL.querySelector('[data-idx="' + idx + '"]'); if (tgt) { tgt.classList.add('selected'); tgt.style.opacity = '1'; } apiGet(tn, an).then(function(d) { renderSong(d); for (var j = 0; j < rows.length; j++) rows[j].style.opacity = '1'; }).catch(function(err) { toast('❌ ' + err.message, true); for (var j = 0; j < rows.length; j++) rows[j].style.opacity = '1'; selectedIdx = -1; }); } function fdur(s) { if (!s && s !== 0) return ''; var m = Math.floor(s / 60); return m + ':' + p2(Math.floor(s % 60)); } SB.addEventListener('click', function() { doSearch(SI.value); }); SI.addEventListener('keydown', function(e) { if (e.key === 'Enter') doSearch(SI.value); }); DB.addEventListener('click', function() { var q = SI.value.trim(); if (!q) { toast('请输入「歌曲名 - 歌手名」', true); return; } var seps = [' - ', '-', ' – ', '–', ' | ', '|', ':', ':']; var tk = '', ar = ''; for (var i = 0; i < seps.length; i++) { if (q.indexOf(seps[i]) !== -1) { var pts = q.split(seps[i]); tk = pts[0].trim(); ar = pts.slice(1).join(seps[i]).trim(); break; } } if (!tk) { tk = prompt('歌曲名:', q); if (!tk) return; ar = prompt('歌手名(可选):', '') || ''; } results = []; selectedIdx = -1; RL.innerHTML = '<div class="spinner-wrap"><span class="spinner"></span>获取中…</div>'; RB.classList.add('open'); apiGet(tk, ar).then(function(d) { results = [d]; selectedIdx = 0; RL.innerHTML = '<div class="result-row selected" data-idx="0" style="opacity:1;"><div class="result-info"><div class="rtrack">' + esc(d.trackName || d.name || '未知') + '</div><div class="rartist">' + esc(d.artistName || '未知') + '</div></div><div class="result-meta">' + fdur(d.duration) + '</div></div>'; renderSong(d); }).catch(function(err) { RL.innerHTML = '<div class="empty">❌ ' + esc(err.message) + '</div>'; toast('❌ ' + err.message, true); }); }); function readBlocks(ab) { var v = new DataView(ab); var b = ab; if (b.byteLength < 4) throw new Error('太小'); if (String.fromCharCode(v.getUint8(0), v.getUint8(1), v.getUint8(2), v.getUint8(3)) !== 'fLaC') throw new Error( '非FLAC'); var bl = []; var o = 4, lb = false; while (o < b.byteLength && !lb) { if (o + 4 > b.byteLength) break; var h = v.getUint8(o); lb = (h & 0x80) !== 0; var bt = h & 0x7F; var bs = (v.getUint8(o + 1) << 16) | (v.getUint8(o + 2) << 8) | v.getUint8(o + 3); var st = o; o += 4; bl.push({ type: bt, last: lb, ho: st, dto: o, size: bs }); o += bs; } return { blocks: bl, buf: b }; } function parseVC(bi, buf) { var bl = new Uint8Array(buf, bi.dto, bi.size); var dec = new TextDecoder('utf-8'); if (bl.length < 4) return { cmts: [] }; var vl = bl[0] | (bl[1] << 8) | (bl[2] << 16) | (bl[3] << 24); var p = 4 + vl; if (p + 4 > bl.length) return { cmts: [] }; var nc = bl[p] | (bl[p + 1] << 8) | (bl[p + 2] << 16) | (bl[p + 3] << 24); p += 4; var cmts = []; for (var i = 0; i < nc; i++) { if (p + 4 > bl.length) break; var cl = bl[p] | (bl[p + 1] << 8) | (bl[p + 2] << 16) | (bl[p + 3] << 24); p += 4; if (p + cl > bl.length) break; cmts.push(dec.decode(bl.slice(p, p + cl))); p += cl; } return { cmts: cmts }; } function buildVC(cmts, isLast) { var enc = new TextEncoder(); var vb = new Uint8Array(4); var cb = new Uint8Array(4); cb[0] = cmts.length & 0xFF; cb[1] = (cmts.length >> 8) & 0xFF; cb[2] = (cmts.length >> 16) & 0xFF; cb[3] = (cmts.length >> 24) & 0xFF; var arrs = []; for (var i = 0; i < cmts.length; i++) { var ed = enc.encode(cmts[i]); var lb = new Uint8Array(4); lb[0] = ed.length & 0xFF; lb[1] = (ed.length >> 8) & 0xFF; lb[2] = (ed.length >> 16) & 0xFF; lb[3] = (ed.length >> 24) & 0xFF; arrs.push(lb); arrs.push(ed); } var tp = 8; for (var j = 0; j < arrs.length; j++) tp += arrs[j].length; var bh = new Uint8Array(4); bh[0] = 4; if (isLast) bh[0] |= 0x80; bh[1] = (tp >> 16) & 0xFF; bh[2] = (tp >> 8) & 0xFF; bh[3] = tp & 0xFF; var res = new Uint8Array(4 + tp); res.set(bh, 0); var off = 4; res.set(vb, off); off += 4; res.set(cb, off); off += 4; for (var k = 0; k < arrs.length; k++) { res.set(arrs[k], off); off += arrs[k].length; } return res; } function embed(file, lc, tn, sm, ov, cb) { var r = new FileReader(); r.onload = function(e) { try { var info = readBlocks(e.target.result); var bl = info.blocks; var buf = info.buf; var vi = -1; for (var i = 0; i < bl.length; i++) { if (bl[i].type === 4) { vi = i; break; } } var nc = []; var tu = tn.toUpperCase(); var pk = [tu]; if (ov.title) pk.push('TITLE'); if (ov.artist) pk.push('ARTIST'); if (ov.album) pk.push('ALBUM'); if (vi >= 0) { var parsed = parseVC(bl[vi], buf); for (var j = 0; j < parsed.cmts.length; j++) { var eq = parsed.cmts[j].indexOf('='); if (eq > 0) { var key = parsed.cmts[j].substring(0, eq).toUpperCase().trim(); if (pk.indexOf( key) === -1) nc.push(parsed.cmts[j]); } else nc.push(parsed.cmts[j]); } } nc.push(tu + '=' + lc); if (ov.title && sm.trackName) nc.push('TITLE=' + sm.trackName); if (ov.artist && sm.artistName) nc.push('ARTIST=' + sm.artistName); if (ov.album && sm.albumName) nc.push('ALBUM=' + sm.albumName); var isLast = (vi >= 0) ? bl[vi].last : false; var nb = buildVC(nc, isLast); var parts = []; if (vi >= 0) { parts.push(new Uint8Array(buf, 0, bl[vi].ho)); parts.push(nb); parts.push(new Uint8Array(buf, bl[vi].dto + bl[vi].size)); } else { var si = -1; for (var k = 0; k < bl.length; k++) { if (bl[k].type === 0) { si = k; break; } } if (si >= 0) { var sho = bl[si].ho; var sa = bl[si].dto + bl[si].size; parts.push(new Uint8Array(buf, 0, sho)); var sh = new Uint8Array(buf, sho, 4); sh[0] = sh[0] & 0x7F; parts.push(sh); parts.push(new Uint8Array(buf, bl[si].dto, bl[si].size)); parts.push(nb); parts.push(new Uint8Array(buf, sa)); } else { parts.push(new Uint8Array(buf, 0, 4)); parts.push(nb); parts.push(new Uint8Array(buf, 4)); } } var tl = 0; for (var p = 0; p < parts.length; p++) tl += parts[p].length; var result = new Uint8Array(tl); var off = 0; for (var q = 0; q < parts.length; q++) { result.set(parts[q], off); off += parts[q].length; } cb(null, new Blob([result], { type: 'audio/flac' })); } catch (x) { cb(x); } }; r.onerror = function() { cb(new Error('读取失败')); }; r.readAsArrayBuffer(file); } EB.addEventListener('click', function() { if (!files.length) { toast('请先添加 FLAC 文件', true); return; } if (!song) { toast('请先选择歌词', true); return; } var lc = getFmt(fmt, song); if (!lc.trim()) { toast('当前格式无内容', true); return; } var ov = { title: OVT.checked, artist: OVA.checked, album: OVAL.checked }; EB.disabled = true; CFB.disabled = true; var extra = []; if (ov.title) extra.push('TITLE'); if (ov.artist) extra.push('ARTIST'); if (ov.album) extra.push('ALBUM'); var em = extra.length ? ' + 覆盖 ' + extra.join('/') : ''; toast('⏳ 正在写入 ' + files.length + ' 个文件' + em + '…'); var ok = 0, fl = 0; function next(idx) { if (idx >= files.length) { EB.disabled = false; CFB.disabled = false; if (fl === 0) toast('🎉 成功!已下载 ' + ok + ' 个 FLAC 文件' + em); else toast('⚠️ ' + ok + ' 成功, ' + fl + ' 失败', true); return; } embed(files[idx].file, lc, tag, song, ov, function(err, blob) { if (err) { fl++; console.error(err); } else { var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = files[idx].file.name; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); ok++; } setTimeout(function() { next(idx + 1); }, 200); }); } next(0); }); updateFiles(); SI.focus(); })(); </script> </body> </html> 歌词搜索、标签写入和元数据覆盖一次性搞定 2 个帖子 - 2 位参与者 阅读完整话题
以 iOS 18 的设计风格做一个带有动画效果的天气卡片,要求是使用 HTML、CSS 和基础 JavaScript,使用横板天气页面(拥有 4 个天气卡片 (晴天,大风,暴雨,暴雪))。应足够美观,实现一定的交互效果。 点击预览: claude-Fable-5-weather-cards 其他模型: Qoder额度用不完,用里面的所有模型跑了天气卡片看看 开发调优 当家花旦-Qwen3.7-Max 六月新茶-MiniMax-M3 编码一哥-DeepSeek-V4-PRO 国产A初-GLM-5.1 后劲不足-Kimi-K2.6 裁判组大善人-GPT-5.5(ui-ux-pro-max skill) 白月光-Claude-Opus-4.8(ui-ux-pro-max skill) 小甜甜牛夫人-Claude-Opus-4.6 北美豆包-Gemini 3.5 F… 你可以永远相信Claude hmmm…感觉差点 酱油通道 点击以查看投票。 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>天气 · iOS 18 风格</title> <style> :root { --spring: cubic-bezier(.32, .72, .25, 1); } * { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; } html, body { height: 100%; } body { font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro SC", "PingFang SC", "Helvetica Neue", "Microsoft YaHei", sans-serif; background: #070a14; color: #fff; display: flex; align-items: center; justify-content: center; overflow: hidden; -webkit-font-smoothing: antialiased; } /* ========== 背景氛围光斑 ========== */ .ambient { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; } .blob { position: absolute; border-radius: 50%; filter: blur(110px); opacity: .55; animation: blobFloat 16s ease-in-out infinite alternate; } .b1 { width: 520px; height: 520px; background: #27408f; top: -180px; left: -120px; } .b2 { width: 460px; height: 460px; background: #5b2a7a; bottom: -160px; right: -100px; animation-delay: -5s; } .b3 { width: 380px; height: 380px; background: #175e5e; bottom: 6%; left: 34%; animation-delay: -10s; opacity: .4; } @keyframes blobFloat { to { transform: translate(70px, 50px) scale(1.18); } } /* ========== 顶部栏 ========== */ .stage { width: min(1240px, 94vw); position: relative; z-index: 1; } .topbar { display: flex; align-items: flex-end; justify-content: space-between; margin: 0 6px 20px; } .topbar h1 { font-size: 34px; font-weight: 700; letter-spacing: 1px; } .sub { margin-top: 6px; font-size: 14px; color: rgba(255,255,255,.62); display: flex; align-items: center; gap: 8px; } .sub svg { opacity: .9; } .glass-chip { display: flex; align-items: center; gap: 9px; padding: 10px 16px; border-radius: 999px; background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.15); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; } .live-dot { width: 8px; height: 8px; border-radius: 50%; background: #34d399; box-shadow: 0 0 10px #34d399; animation: pulseDot 2s infinite; } @keyframes pulseDot { 50% { opacity: .35; } } /* ========== 卡片容器 ========== */ .cards { display: flex; gap: 18px; height: min(600px, 68vh); min-height: 480px; perspective: 1600px; } .card { position: relative; flex: 1; border-radius: 32px; overflow: hidden; cursor: pointer; user-select: none; outline: none; border: 1px solid rgba(255,255,255,.28); box-shadow: 0 24px 48px -12px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.35); transition: flex-grow .7s var(--spring), min-height .7s var(--spring), box-shadow .45s ease, border-color .45s ease, transform .25s ease-out; animation: cardIn .9s cubic-bezier(.22, 1, .36, 1) backwards; transform-style: preserve-3d; } .card:nth-child(1) { animation-delay: .05s; } .card:nth-child(2) { animation-delay: .15s; } .card:nth-child(3) { animation-delay: .25s; } .card:nth-child(4) { animation-delay: .35s; } @keyframes cardIn { from { opacity: 0; transform: translateY(44px) scale(.96); } } .card:hover { box-shadow: 0 30px 60px -14px rgba(0,0,0,.62); } .card:not(.active):hover { transform: translateY(-8px); } .card:focus-visible { border-color: rgba(255,255,255,.85); } .card.active { flex-grow: 2.6; border-color: rgba(255,255,255,.5); box-shadow: 0 34px 70px -16px rgba(0,0,0,.68), inset 0 1px 0 rgba(255,255,255,.45); } /* 底部压暗,保证文字可读性 */ .card::after { content: ''; position: absolute; inset: 0; z-index: 1; pointer-events: none; background: linear-gradient(to top, rgba(8,15,30,.45), rgba(8,15,30,0) 48%); } /* 四种天气底色 */ .sunny { background: linear-gradient(180deg, #1f6dd1 0%, #4f9be6 40%, #9ccdf0 72%, #ffd9a0 100%); } .windy { background: linear-gradient(180deg, #23606e 0%, #3d8d8a 48%, #7cc4ad 82%, #b9e2cf 100%); } .rainstorm { background: linear-gradient(180deg, #151d2c 0%, #243349 52%, #3a516c 100%); } .blizzard { background: linear-gradient(180deg, #46688f 0%, #6e93b4 45%, #a9c7da 78%, #e6f2f8 100%); } /* ========== 卡片内容 ========== */ .content { position: relative; z-index: 3; height: 100%; display: flex; flex-direction: column; padding: 22px; } .row-top { display: flex; justify-content: space-between; gap: 8px; } .chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 11px; font-size: 12px; font-weight: 600; border-radius: 999px; white-space: nowrap; background: rgba(255,255,255,.16); border: 1px solid rgba(255,255,255,.22); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); } .chip.warn { background: rgba(255,159,67,.28); border-color: rgba(255,159,67,.5); } .chip.danger { background: rgba(255,107,107,.3); border-color: rgba(255,107,107,.52); } .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--c,#fff); box-shadow: 0 0 8px var(--c,#fff); } .bottom { margin-top: auto; } .w-eng { font-size: 11px; letter-spacing: 3px; opacity: .7; font-weight: 600; } .w-name { font-size: 20px; font-weight: 600; letter-spacing: 2px; margin-top: 2px; text-shadow: 0 2px 12px rgba(0,0,0,.3); } .temp { font-weight: 200; line-height: 1.05; letter-spacing: -2px; font-size: clamp(56px, 6.2vw, 78px); text-shadow: 0 4px 24px rgba(0,0,0,.28); transition: font-size .6s var(--spring); font-variant-numeric: tabular-nums; } .card:not(.active) .temp { font-size: clamp(40px, 4.2vw, 52px); font-weight: 300; } .range { font-size: 13.5px; color: rgba(255,255,255,.88); margin-top: 2px; text-shadow: 0 1px 8px rgba(0,0,0,.3); } /* 展开区域 */ .reveal { max-height: 0; opacity: 0; transform: translateY(16px); overflow: hidden; transition: max-height .7s var(--spring), opacity .5s ease .08s, transform .65s var(--spring); } .card.active .reveal { max-height: 340px; opacity: 1; transform: none; } .desc { font-size: 13px; color: rgba(255,255,255,.78); margin-top: 10px; } .metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-top: 14px; } .metric { text-align: center; padding: 10px 6px; border-radius: 16px; overflow: hidden; background: rgba(255,255,255,.14); border: 1px solid rgba(255,255,255,.18); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); } .metric .k { font-size: 11px; color: rgba(255,255,255,.68); } .metric .v { font-size: 14px; font-weight: 600; margin-top: 3px; } .hours { display: flex; gap: 8px; margin-top: 10px; } .hour { flex: 1; text-align: center; padding: 9px 4px; border-radius: 14px; background: rgba(255,255,255,.1); border: 1px solid rgba(255,255,255,.14); } .hour .t { font-size: 11px; color: rgba(255,255,255,.65); } .hour .v { font-size: 13px; font-weight: 600; margin-top: 4px; } /* 鼠标跟随高光 */ .glare { position: absolute; inset: 0; z-index: 2; pointer-events: none; opacity: 0; transition: opacity .35s; background: radial-gradient(420px circle at var(--mx,50%) var(--my,50%), rgba(255,255,255,.2), transparent 46%); } .card:hover .glare { opacity: 1; } /* ========== 场景动画层 ========== */ .scene { position: absolute; inset: 0; z-index: 1; pointer-events: none; } /* 云朵(通用) */ .cloud { position: absolute; width: 130px; height: 42px; background: #fff; border-radius: 40px; filter: drop-shadow(0 16px 22px rgba(0,0,0,.22)); } .cloud::before, .cloud::after { content: ''; position: absolute; background: inherit; border-radius: 50%; } .cloud::before { width: 56px; height: 56px; top: -27px; left: 20px; } .cloud::after { width: 36px; height: 36px; top: -17px; right: 22px; } .cloud.sm { width: 84px; height: 28px; } .cloud.sm::before { width: 38px; height: 38px; top: -18px; left: 12px; } .cloud.sm::after { width: 26px; height: 26px; top: -12px; right: 12px; } @keyframes cloudBob { from { transform: translateX(-12px); } to { transform: translateX(12px); } } /* —— 晴天 —— */ .sun-anchor { position: absolute; top: 132px; left: 50%; width: 0; height: 0; } .sun { position: absolute; left: -46px; top: -46px; width: 92px; height: 92px; border-radius: 50%; background: radial-gradient(circle at 35% 32%, #fff8d6, #ffd94e 48%, #ffae33 78%, #ff9d1f); animation: sunPulse 4.5s ease-in-out infinite; } @keyframes sunPulse { 0%, 100% { transform: scale(1); box-shadow: 0 0 34px 6px rgba(255,205,80,.8), 0 0 90px 28px rgba(255,170,60,.32); } 50% { transform: scale(1.06); box-shadow: 0 0 48px 12px rgba(255,205,80,.95), 0 0 120px 40px rgba(255,170,60,.45); } } .rays { position: absolute; left: 0; top: 0; animation: spin 28s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .ray { position: absolute; left: -2.5px; top: -9px; width: 5px; height: 18px; border-radius: 3px; background: linear-gradient(to top, rgba(255,228,130,.95), rgba(255,228,130,0)); transform: rotate(var(--a)) translateY(-76px); transform-origin: 2.5px 9px; } .sunny .c1 { top: 228px; left: 12%; opacity: .85; animation: cloudBob 9s ease-in-out infinite alternate; } .sunny .c2 { top: 58px; right: 8%; opacity: .6; animation: cloudBob 12s ease-in-out infinite alternate-reverse; } /* —— 大风 —— */ .windline { position: absolute; height: 3px; border-radius: 4px; opacity: 0; background: linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,.85), rgba(255,255,255,0)); animation: windMove linear infinite; } @keyframes windMove { 0% { transform: translateX(0); opacity: 0; } 10% { opacity: .85; } 80% { opacity: .85; } 100% { transform: translateX(840px); opacity: 0; } } .leaf { position: absolute; width: 11px; height: 11px; border-radius: 0 60% 0 60%; background: linear-gradient(135deg, #9fdc85, #4f9e4f); animation: leafFly linear infinite; } @keyframes leafFly { to { transform: translate(840px, 90px) rotate(680deg); } } .windy .cloud.main { top: 128px; left: 50%; margin-left: -65px; background: #eef7f4; animation: cloudBob 4s ease-in-out infinite alternate; } .windy .cloud.back { top: 92px; left: 50%; margin-left: -126px; background: #d7e8e2; opacity: .75; animation: cloudBob 5.5s ease-in-out infinite alternate-reverse; } /* —— 暴雨 —— */ .rain { position: absolute; inset: -60px; transform: rotate(9deg); } .drop { position: absolute; top: -30px; width: 2px; border-radius: 2px; background: linear-gradient(to bottom, rgba(173,216,255,0), rgba(173,216,255,.75)); animation: fall linear infinite; } @keyframes fall { to { transform: translateY(780px); } } .rainstorm .cloud.main { top: 118px; left: 50%; margin-left: -65px; background: #39465a; } .rainstorm .cloud.back { top: 90px; left: 50%; margin-left: -118px; background: #26303f; opacity: .92; animation: cloudBob 7s ease-in-out infinite alternate; } .bolt { position: absolute; top: 150px; left: 50%; margin-left: -15px; width: 30px; height: 48px; background: linear-gradient(#ffe879, #ffb62e); clip-path: polygon(58% 0, 0 55%, 38% 55%, 28% 100%, 100% 38%, 52% 38%); filter: drop-shadow(0 0 14px rgba(255,210,80,.9)); opacity: 0; animation: boltIdle 7s linear infinite; } @keyframes boltIdle { 0%, 85%, 100% { opacity: 0; } 87% { opacity: 1; } 89% { opacity: .15; } 91% { opacity: .9; } 95% { opacity: 0; } } .flash-layer { position: absolute; inset: 0; z-index: 4; pointer-events: none; opacity: 0; background: radial-gradient(circle at 50% 0%, rgba(255,255,255,.95), rgba(255,255,255,0) 72%); } .card.flashing .flash-layer { animation: skyFlash .85s ease-out; } .card.flashing .bolt { animation: boltFlash .85s ease both; } @keyframes skyFlash { 0% { opacity: 0; } 8% { opacity: .85; } 16% { opacity: .15; } 26% { opacity: .6; } 42% { opacity: .1; } 56% { opacity: .3; } 100% { opacity: 0; } } @keyframes boltFlash { 0% { opacity: 0; } 6% { opacity: 1; } 14% { opacity: .2; } 22% { opacity: 1; } 60% { opacity: .85; } 100% { opacity: 0; } } /* —— 暴雪 —— */ .flake { position: absolute; top: -12px; border-radius: 50%; background: #fff; animation: snow linear infinite; } @keyframes snow { to { transform: translate(var(--dx, 50px), 760px); } } .blizzard .windline { filter: opacity(.45); } .blizzard .cloud.main { top: 112px; left: 50%; margin-left: -65px; background: #f3f8fc; animation: cloudBob 6s ease-in-out infinite alternate; } .blizzard .cloud.back { top: 84px; left: 50%; margin-left: -116px; background: #c9d9e6; opacity: .8; animation: cloudBob 8s ease-in-out infinite alternate-reverse; } .snow-ground { position: absolute; left: -12%; right: -12%; height: 84px; border-radius: 50% 50% 0 0 / 56px; } .g2 { background: rgba(210,230,243,.75); bottom: -26px; left: -20%; right: -4%; } .g1 { background: rgba(255,255,255,.92); bottom: -40px; } .hint { text-align: center; margin-top: 18px; font-size: 13px; color: rgba(255,255,255,.45); letter-spacing: 1px; } /* ========== 响应式 & 无障碍 ========== */ @media (max-width: 880px) { body { overflow: auto; padding: 28px 0; align-items: flex-start; } .cards { flex-direction: column; height: auto; min-height: 0; } .card { flex: none; min-height: 150px; } .card.active { min-height: 430px; } .metrics { grid-template-columns: repeat(2, 1fr); } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .2s !important; } } </style> </head> <body> <div class="ambient"> <div class="blob b1"></div><div class="blob b2"></div><div class="blob b3"></div> </div> <div class="stage"> <header class="topbar"> <div> <h1>天气</h1> <div class="sub"> <svg width="13" height="13" viewBox="0 0 24 24" fill="none"> <path d="M12 21s-7-5.1-7-11a7 7 0 1 1 14 0c0 5.9-7 11-7 11z" stroke="white" stroke-opacity=".7" stroke-width="2"/> <circle cx="12" cy="10" r="2.6" fill="white" fill-opacity=".85"/> </svg> <span>北京市 · 朝阳区</span><span>·</span><span id="dateText"></span> </div> </div> <div class="glass-chip"><span class="live-dot"></span><span id="clockText">--:--:--</span></div> </header> <main class="cards"> <!-- ☀️ 晴天 --> <article class="card sunny active" tabindex="0" role="button" aria-expanded="true"> <div class="scene"> <div class="sun-anchor"><div class="rays"></div><div class="sun"></div></div> <div class="cloud sm c1"></div><div class="cloud sm c2"></div> </div> <div class="content"> <div class="row-top"> <span class="chip"><i class="dot" style="--c:#ffd54d"></i>晴</span> <span class="chip warn">紫外线 强</span> </div> <div class="bottom"> <p class="w-eng">SUNNY</p><h2 class="w-name">晴天</h2> <div class="temp"><span class="temp-num" data-target="28">0</span>°</div> <p class="range">最高 31° · 最低 22° · 体感 30°</p> <div class="reveal"><div> <p class="desc">阳光明媚,紫外线较强,外出请注意防晒与补水。</p> <div class="metrics"> <div class="metric"><p class="k">湿度</p><p class="v">42%</p></div> <div class="metric"><p class="k">风速</p><p class="v">8 km/h</p></div> <div class="metric"><p class="k">气压</p><p class="v">1013 hPa</p></div> <div class="metric"><p class="k">能见度</p><p class="v">24 km</p></div> </div> <div class="hours"> <div class="hour"><p class="t">现在</p><p class="v">28°</p></div> <div class="hour"><p class="t">15时</p><p class="v">29°</p></div> <div class="hour"><p class="t">16时</p><p class="v">29°</p></div> <div class="hour"><p class="t">17时</p><p class="v">27°</p></div> <div class="hour"><p class="t">18时</p><p class="v">25°</p></div> </div> </div></div> </div> </div> <div class="glare"></div> </article> <!-- 🌬️ 大风 --> <article class="card windy" tabindex="0" role="button" aria-expanded="false"> <div class="scene"> <div class="windfield"></div> <div class="cloud back sm"></div><div class="cloud main"></div> </div> <div class="content"> <div class="row-top"> <span class="chip"><i class="dot" style="--c:#7fe3c4"></i>大风</span> <span class="chip warn">阵风 7 级</span> </div> <div class="bottom"> <p class="w-eng">WINDY</p><h2 class="w-name">大风</h2> <div class="temp"><span class="temp-num" data-target="21">0</span>°</div> <p class="range">最高 23° · 最低 17° · 体感 18°</p> <div class="reveal"><div> <p class="desc">阵风明显,出行请远离临时搭建物,注意高空坠物。</p> <div class="metrics"> <div class="metric"><p class="k">湿度</p><p class="v">55%</p></div> <div class="metric"><p class="k">阵风</p><p class="v">52 km/h</p></div> <div class="metric"><p class="k">风向</p><p class="v">西北</p></div> <div class="metric"><p class="k">能见度</p><p class="v">18 km</p></div> </div> <div class="hours"> <div class="hour"><p class="t">现在</p><p class="v">21°</p></div> <div class="hour"><p class="t">15时</p><p class="v">21°</p></div> <div class="hour"><p class="t">16时</p><p class="v">20°</p></div> <div class="hour"><p class="t">17时</p><p class="v">19°</p></div> <div class="hour"><p class="t">18时</p><p class="v">18°</p></div> </div> </div></div> </div> </div> <div class="glare"></div> </article> <!-- ⛈️ 暴雨 --> <article class="card rainstorm" tabindex="0" role="button" aria-expanded="false"> <div class="scene"> <div class="rain"></div> <div class="bolt"></div> <div class="cloud back sm"></div><div class="cloud main"></div> </div> <div class="flash-layer"></div> <div class="content"> <div class="row-top"> <span class="chip"><i class="dot" style="--c:#6db3ff"></i>暴雨</span> <span class="chip danger">雷电预警</span> </div> <div class="bottom"> <p class="w-eng">STORM</p><h2 class="w-name">暴雨</h2> <div class="temp"><span class="temp-num" data-target="23">0</span>°</div> <p class="range">最高 24° · 最低 20° · 体感 25°</p> <div class="reveal"><div> <p class="desc">雷雨持续,局地伴有强雷电,请减少外出避开积水。</p> <div class="metrics"> <div class="metric"><p class="k">湿度</p><p class="v">92%</p></div> <div class="metric"><p class="k">降水量</p><p class="v">38 mm</p></div> <div class="metric"><p class="k">风速</p><p class="v">24 km/h</p></div> <div class="metric"><p class="k">能见度</p><p class="v">1.2 km</p></div> </div> <div class="hours"> <div class="hour"><p class="t">现在</p><p class="v">23°</p></div> <div class="hour"><p class="t">15时</p><p class="v">22°</p></div> <div class="hour"><p class="t">16时</p><p class="v">22°</p></div> <div class="hour"><p class="t">17时</p><p class="v">21°</p></div> <div class="hour"><p class="t">18时</p><p class="v">21°</p></div> </div> </div></div> </div> </div> <div class="glare"></div> </article> <!-- 🌨️ 暴雪 --> <article class="card blizzard" tabindex="0" role="button" aria-expanded="false"> <div class="scene"> <div class="snowfield"></div> <div class="gusts"></div> <div class="cloud back sm"></div><div class="cloud main"></div> <div class="snow-ground g2"></div><div class="snow-ground g1"></div> </div> <div class="content"> <div class="row-top"> <span class="chip"><i class="dot" style="--c:#cfe9ff"></i>暴雪</span> <span class="chip danger">暴雪预警</span> </div> <div class="bottom"> <p class="w-eng">BLIZZARD</p><h2 class="w-name">暴雪</h2> <div class="temp"><span class="temp-num" data-target="-8">0</span>°</div> <p class="range">最高 -6° · 最低 -15° · 体感 -14°</p> <div class="reveal"><div> <p class="desc">暴雪橙色预警,道路湿滑结冰,请注意保暖与防滑。</p> <div class="metrics"> <div class="metric"><p class="k">湿度</p><p class="v">78%</p></div> <div class="metric"><p class="k">风速</p><p class="v">36 km/h</p></div> <div class="metric"><p class="k">积雪</p><p class="v">12 cm</p></div> <div class="metric"><p class="k">能见度</p><p class="v">0.8 km</p></div> </div> <div class="hours"> <div class="hour"><p class="t">现在</p><p class="v">-8°</p></div> <div class="hour"><p class="t">15时</p><p class="v">-9°</p></div> <div class="hour"><p class="t">16时</p><p class="v">-10°</p></div> <div class="hour"><p class="t">17时</p><p class="v">-11°</p></div> <div class="hour"><p class="t">18时</p><p class="v">-12°</p></div> </div> </div></div> </div> </div> <div class="glare"></div> </article> </main> <p class="hint">点击卡片展开 / 收起详情 · 悬停体验光影与 3D 视差</p> </div> <script> const rand = (a, b) => Math.random() * (b - a) + a; const cards = [...document.querySelectorAll('.card')]; /* ---------- 粒子生成 ---------- */ function spawn(sel, n, fn) { const box = document.querySelector(sel); if (!box) return; for (let i = 0; i < n; i++) box.appendChild(fn(i)); } // 太阳光芒 ×12 spawn('.sunny .rays', 12, i => { const r = document.createElement('i'); r.className = 'ray'; r.style.setProperty('--a', i * 30 + 'deg'); return r; }); // 雨滴 ×64 spawn('.rainstorm .rain', 64, () => { const d = document.createElement('span'); d.className = 'drop'; d.style.left = rand(0, 100) + '%'; d.style.height = rand(10, 20) + 'px'; d.style.opacity = rand(.3, .85); d.style.animationDuration = rand(.55, 1.05) + 's'; d.style.animationDelay = -rand(0, 2) + 's'; return d; }); // 雪花 ×46 spawn('.blizzard .snowfield', 46, () => { const f = document.createElement('span'); f.className = 'flake'; const s = rand(3, 8); f.style.width = f.style.height = s + 'px'; f.style.left = rand(-10, 100) + '%'; f.style.opacity = rand(.45, .95); f.style.setProperty('--dx', rand(30, 140) + 'px'); f.style.animationDuration = rand(4.5, 9) + 's'; f.style.animationDelay = -rand(0, 9) + 's'; if (Math.random() < .3) f.style.filter = 'blur(1px)'; return f; }); // 风线(大风 ×9,暴雪 ×5) const makeWindline = () => { const w = document.createElement('span'); w.className = 'windline'; w.style.top = rand(8, 82) + '%'; w.style.left = '-220px'; w.style.width = rand(80, 200) + 'px'; w.style.animationDuration = rand(1.6, 3.2) + 's'; w.style.animationDelay = -rand(0, 3) + 's'; return w; }; spawn('.windy .windfield', 9, makeWindline); spawn('.blizzard .gusts', 5, makeWindline); // 落叶 ×6 spawn('.windy .windfield', 6, () => { const l = document.createElement('span'); l.className = 'leaf'; l.style.left = '-40px'; l.style.top = rand(18, 72) + '%'; l.style.animationDuration = rand(3, 6) + 's'; l.style.animationDelay = -rand(0, 6) + 's'; return l; }); /* ---------- 卡片交互:点击展开 + 3D 视差 + 高光 ---------- */ cards.forEach(card => { card.addEventListener('click', () => { const on = card.classList.contains('active'); cards.forEach(c => { c.classList.remove('active'); c.setAttribute('aria-expanded', 'false'); }); if (!on) { card.classList.add('active'); card.setAttribute('aria-expanded', 'true'); } }); card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); card.click(); } }); card.addEventListener('mousemove', e => { const r = card.getBoundingClientRect(); const x = e.clientX - r.left, y = e.clientY - r.top; card.style.setProperty('--mx', x + 'px'); card.style.setProperty('--my', y + 'px'); const ry = ((x / r.width) - .5) * 8; const rx = ((y / r.height) - .5) * -6; card.style.transform = `translateY(-6px) rotateX(${rx}deg) rotateY(${ry}deg)`; }); card.addEventListener('mouseleave', () => { card.style.transform = ''; }); }); /* ---------- 随机闪电 ---------- */ const storm = document.querySelector('.rainstorm'); (function lightning() { setTimeout(() => { storm.classList.add('flashing'); setTimeout(() => storm.classList.remove('flashing'), 900); lightning(); }, rand(2200, 7000)); })(); /* ---------- 温度数字滚动 ---------- */ document.querySelectorAll('.temp-num').forEach(el => { const target = parseInt(el.dataset.target, 10); const t0 = performance.now(), dur = 1500; (function step(now) { const p = Math.min((now - t0) / dur, 1); el.textContent = Math.round(target * (1 - Math.pow(1 - p, 3))); if (p < 1) requestAnimationFrame(step); })(t0); }); /* ---------- 实时时钟 ---------- */ const clockEl = document.getElementById('clockText'); const dateEl = document.getElementById('dateText'); const weeks = ['周日','周一','周二','周三','周四','周五','周六']; function tick() { const d = new Date(), p = n => String(n).padStart(2, '0'); clockEl.textContent = `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; dateEl.textContent = `${d.getMonth() + 1}月${d.getDate()}日 ${weeks[d.getDay()]}`; } tick(); setInterval(tick, 1000); </script> </body> </html> 15 个帖子 - 10 位参与者 阅读完整话题
刚刚火急火燎测试了一下 fable5 牛炸天的前端能力 做了一个 html(比较隐私就不发上来了) 体感比较明确的就是 fable5 在前端上开始学会做减法 这个 html 原本我是用 opus4.8 做的 有一些看起来比较杂的模块和动画直接被 fable5 删了 fable5 做出来的 html 的动画逻辑明显要比 opus4.8 好很多 但是绝对称不上满分称不上眼前一亮 如果 opus 是 80 分 那 fable 也就是 90 纯体感 1 个帖子 - 1 位参与者 阅读完整话题
以 iOS 18 的设计风格做一个带有动画效果的天气卡片,要求是使用 HTML、CSS 和基础 JavaScript,使用横板天气页面(拥有 4 个天气卡片 (晴天,大风,暴雨,暴雪))。应足够美观,实现一定的交互效果。 总花费5m41s 效果图: html (点击了解更多详细信息) 18 个帖子 - 14 位参与者 阅读完整话题
<!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 位参与者 阅读完整话题
使用中转站的gpt5.5直接在一个文件夹中让其制作出一个单页面的html登录页,结果一直卡在使用各种工具和skill上,是什么情况,各位佬哥遇见了没,该如何解决呢? 2 个帖子 - 2 位参与者 阅读完整话题
从 【any生图】 纯前端html项目, any的claude一直429用不了?那就拿来生图吧 这里继续 现在只会输入文字了 然后直接对话问了下 能生图的公益站和any站的内置工具有哪些, 回答如下 能生图的公益站 any站点 5 个帖子 - 4 位参与者 阅读完整话题
I’ve built htmlvideo.ai , a curated hub for Remotion-based apps, templates and real-world video use cases. As the foundational technology for programmable video rendering in the community, Remotion powers nearly all high-performance browser-side video rendering today. With the rapid advancement of AI Coding Agents, we can expect an explosion of code-generated videos with increasingly polished quality. I created this site to help myself and fellow developers learn and adopt Remotion more efficiently. My hope is that htmlvideo.ai will serve as a starting point for the great journey of HTML as Video in the AI era. I will keep updating this site continuously, until everyone can easily leverage AI agents including Claude, Codex, OpenClaw and Hermes to create and edit custom videos effortlessly.
收到最近AI Respon as HTML的启发,做了个HTML功能,但感觉有点廉价 佬们有什么思路吗?我该怎么让模型写出更好的HTML回复? 3 个帖子 - 3 位参与者 阅读完整话题