WWW.YOUINFO.SITE
标签聚合 lang

/tag/lang

LinuxDo 最新话题 · 2026-06-10 13:07:20+08:00 · tech

<!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 位参与者 阅读完整话题

LinuxDo 最新话题 · 2026-06-10 11:36:24+08:00 · tech

证件照换底(支持自定义颜色代码) <!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 位参与者 阅读完整话题

LinuxDo 最新话题 · 2026-06-10 10:51:41+08:00 · tech

<!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 位参与者 阅读完整话题

v2ex · 2026-06-10 08:06:02+08:00 · tech

核心要求(精通) Golang Gin 或 Go-Zero 框架 PostgreSQL Redis (缓存、限流、状态管理) WebSocket (实时通信、低延迟,实时最高 500 同时在线) REST API 设计 JWT/Token 认证机制 安全设计(权限控制、防越权、防刷接口、接口安全、数据保护等) 具备独立系统设计能力(模块拆分、服务解耦、状态流设计) 网页防爬虫 熟练使用 AI ,减少开发周期 前端要求(熟悉) Vue3 TypeScript Vite WebSocket 前端通信 Pinia/状态管理 Element Plus 或类似后台框架 部署要求(熟悉) Docker Linux 服务器部署 Nginx CI/CD 基础流程 最好有过的其中的经验 SaaS 系统开发经验 私有化部署系统经验 IM/在线客服/实时监控系统经验 SDK 或平台型产品开发经验 低代码平台开发经验 可扩展系统架构设计经验 可视化编辑器开发经验 拖拽搭建系统开发经验 Tauri 桌面应用开发经验 对<实践论><矛盾论>有较深理解 希望你: 人品好,有原则,有责任心,合作精神 可远程合作,但要求全职 能力过硬,能独立负责开发,维护,效率高 沟通及时,沟通理解能力强 身体健康,少/不喝酒 请你提供: 个人情况 个人项目案例 项目和股权简介 项目正在运营,现产品技术弱水平不够,急需一款高性能的产品垄断市场。 有初代产品(低质量)验证市场和盈利(非正式推广)的 5 个月营收流水 7 位数明细。 有三年行业经验总结和最全面的行业旗舰产品完整设计方案。 现有客户展示,不是构思项目阶段。 合作 50/50 股份,你负责全部技术问题,我负责推广营销变现。 项目年盈利高于 8 位数(保守) 开发周期,预计 2-4 个月(含主要 bug 修复,主性能优化),主要功能完善后即可开始投放。 投放即有老客户付费内测,在主体性能稳定后正式推广,周期大概 1 个月后有稳定收入,且逐月高递增,3-5 月后趋于稳定状态,半年后几乎垄断市场。 项目寿命预计 5 年内稳定 联系方式: 请发邮件到: [email protected] 大概介绍一些您的情况和联系方式。以及对<实践论><矛盾论>的理解。

v2ex · 2026-06-10 08:06:02+08:00 · tech

核心要求(精通) Golang Gin 或 Go-Zero 框架 PostgreSQL Redis (缓存、限流、状态管理) WebSocket (实时通信、低延迟,实时最高 500 同时在线) REST API 设计 JWT/Token 认证机制 安全设计(权限控制、防越权、防刷接口、接口安全、数据保护等) 具备独立系统设计能力(模块拆分、服务解耦、状态流设计) 网页防爬虫 熟练使用 AI ,减少开发周期 前端要求(熟悉) Vue3 TypeScript Vite WebSocket 前端通信 Pinia/状态管理 Element Plus 或类似后台框架 部署要求(熟悉) Docker Linux 服务器部署 Nginx CI/CD 基础流程 最好有过的其中的经验 SaaS 系统开发经验 私有化部署系统经验 IM/在线客服/实时监控系统经验 SDK 或平台型产品开发经验 低代码平台开发经验 可扩展系统架构设计经验 可视化编辑器开发经验 拖拽搭建系统开发经验 Tauri 桌面应用开发经验 对<实践论><矛盾论>有较深理解 希望你: 人品好,有原则,有责任心,合作精神 可远程合作,但要求全职 能力过硬,能独立负责开发,维护,效率高 沟通及时,沟通理解能力强 身体健康,少/不喝酒 请你提供: 个人情况 个人项目案例 项目和股权简介 项目正在运营,现产品技术弱水平不够,急需一款高性能的产品垄断市场。 有初代产品(低质量)验证市场和盈利(非正式推广)的 5 个月营收流水 7 位数明细。 有三年行业经验总结和最全面的行业旗舰产品完整设计方案。 现有客户展示,不是构思项目阶段。 合作 50/50 股份,你负责全部技术问题,我负责推广营销变现。 项目年盈利高于 8 位数(保守) 开发周期,预计 2-4 个月(含主要 bug 修复,主性能优化),主要功能完善后即可开始投放。 投放即有老客户付费内测,在主体性能稳定后正式推广,周期大概 1 个月后有稳定收入,且逐月高递增,3-5 月后趋于稳定状态,半年后几乎垄断市场。 项目寿命预计 5 年内稳定 联系方式: 请发邮件到: [email protected] 大概介绍一些您的情况和联系方式。以及对<实践论><矛盾论>的理解。

LinuxDo 最新话题 · 2026-06-10 07:52:40+08:00 · tech

说实话一般,而且页面卡卡的掉帧 <!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 位参与者 阅读完整话题

LinuxDo 最新话题 · 2026-06-10 00:32:06+08:00 · tech

<!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 位参与者 阅读完整话题

LinuxDo 最新话题 · 2026-06-10 00:28:35+08:00 · tech

<!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 位参与者 阅读完整话题

v2ex · 2026-06-09 23:43:27+08:00 · tech

核心要求(精通) Golang Gin 或 Go-Zero 框架 PostgreSQL Redis (缓存、限流、状态管理) WebSocket (实时通信、低延迟,实时最高 500 同时在线) REST API 设计 JWT/Token 认证机制 安全设计(权限控制、防越权、防刷接口、接口安全、数据保护等) 具备独立系统设计能力(模块拆分、服务解耦、状态流设计) 网页防爬虫 熟练使用 AI ,减少开发周期 前端要求(熟悉) Vue3 TypeScript Vite WebSocket 前端通信 Pinia/状态管理 Element Plus 或类似后台框架 部署要求(熟悉) Docker Linux 服务器部署 Nginx CI/CD 基础流程 最好有过的其中的经验 SaaS 系统开发经验 私有化部署系统经验 IM/在线客服/实时监控系统经验 SDK 或平台型产品开发经验 低代码平台开发经验 可扩展系统架构设计经验 可视化编辑器开发经验 拖拽搭建系统开发经验 Tauri 桌面应用开发经验 对<实践论><矛盾论>有较深理解 希望你: 人品好,有原则,有责任心,合作精神 可远程合作,但要求全职 能力过硬,能独立负责开发,维护,效率高 沟通及时,沟通理解能力强 身体健康,少/不喝酒 请你提供: 个人情况 个人项目案例 项目和股权简介 项目正在运营,现产品技术弱水平不够,急需一款高性能的产品垄断市场。 有初代产品(低质量)验证市场和盈利(非正式推广)的 5 个月营收流水 7 位数明细。 有三年行业经验总结和最全面的行业旗舰产品完整设计方案。 现有客户展示,不是构思项目阶段。 合作 50/50 股份,你负责全部技术问题,我负责推广营销变现。 项目年盈利高于 8 位数(保守) 开发周期,预计 2-4 个月(含主要 bug 修复,主性能优化),主要功能完善后即可开始投放。 投放即有老客户付费内测,在主体性能稳定后正式推广,周期大概 1 个月后有稳定收入,且逐月高递增,3-5 月后趋于稳定状态,半年后几乎垄断市场。 项目寿命预计 5 年内稳定 联系方式: 请发邮件到: [email protected] 大概介绍一些您的情况和联系方式。以及对<实践论><矛盾论>的理解。

v2ex · 2026-06-04 14:04:14+08:00 · tech

招聘标题 AI Agent 架构师( Multi-Agent / LangGraph / MCP ) 职位描述 岗位职责 加入效率工程团队,深度参与 AI Agent 产品体系建设,负责企业级智能效率工具的设计、研发与落地,打造下一代 AI 原生生产力平台; 主导 Context Engineering (上下文工程)与 Multi-Agent 架构设计,构建 Agent Skill 生态、Tool Calling 体系及 Workflow 编排平台,持续扩展 Agent 能力边界; 负责 AI Agent 系统架构设计与核心模块开发,包括 Prompt Pipeline 、Memory System 、Planning System 、Tool System 等关键能力建设,提升 Agent 智能决策与任务执行能力; 构建高可用、高性能 AI Agent 服务体系,解决复杂业务场景下的高并发、低延迟及稳定性问题,保障系统持续可靠运行; 深入探索 Agent 应用场景创新,设计面向不同角色与业务场景的智能工作流,实现 AI 能力的规模化落地; 跟踪并落地前沿 AI Agent 技术方向,包括但不限于: Multi-Agent 协作 Long Context Engineering MCP ( Model Context Protocol ) Function Calling Tool Use Memory Architecture Agent Workflow AI Coding Agent 与产品、研发及业务团队协同合作,推动 AI 能力快速转化为实际业务价值。 任职要求 AI Agent 能力要求 拥有完整 AI Agent 或 LLM 应用开发经验,能够独立完成 Agent 系统设计与实现; 深入理解主流 Agent 范式,包括但不限于: ReAct Plan-and-Execute Reflection Tool Use Multi-Agent Collaboration 熟悉以下核心能力建设: Prompt Engineering Context Engineering Long Context Management Memory Management Tool Calling RAG Evaluation Framework 具备 Agent 系统效果评测与优化经验,能够针对实际业务场景持续提升 Agent 效果与稳定性; 有 Agent Workflow 编排平台建设经验者优先。 技术栈要求 熟悉以下一种或多种 AI Agent 框架: Agent Framework LangGraph LangChain AI Infra MCP ( Model Context Protocol ) Function Calling RAG Vector Database 模型能力 熟悉主流大模型生态: GPT 系列 Claude 系列 DeepSeek 系列 Qwen 系列 Gemini 系列 具备模型评测、Prompt 优化及 Agent 效果调优经验者优先。 加分项 有 Cursor 、Claude Code 、Codex 等 AI Coding 产品深度使用经验; 有 OpenHands 、Devin 、Claude Code 类项目开发经验; 有 MCP Server 开发经验; 有 Agent Framework 二次开发经验; 有 AI 产品从 0 到 1 落地经验; 有开源项目贡献经历; 有技术博客或技术社区影响力。 联系方式 [email protected]

v2ex · 2026-06-04 13:49:31+08:00 · tech

招聘标题 AI Agent 架构师( Multi-Agent / LangGraph / MCP ) 职位描述 岗位职责 加入效率工程团队,深度参与 AI Agent 产品体系建设,负责企业级智能效率工具的设计、研发与落地,打造下一代 AI 原生生产力平台; 主导 Context Engineering (上下文工程)与 Multi-Agent 架构设计,构建 Agent Skill 生态、Tool Calling 体系及 Workflow 编排平台,持续扩展 Agent 能力边界; 负责 AI Agent 系统架构设计与核心模块开发,包括 Prompt Pipeline 、Memory System 、Planning System 、Tool System 等关键能力建设,提升 Agent 智能决策与任务执行能力; 构建高可用、高性能 AI Agent 服务体系,解决复杂业务场景下的高并发、低延迟及稳定性问题,保障系统持续可靠运行; 深入探索 Agent 应用场景创新,设计面向不同角色与业务场景的智能工作流,实现 AI 能力的规模化落地; 跟踪并落地前沿 AI Agent 技术方向,包括但不限于: Multi-Agent 协作 Long Context Engineering MCP ( Model Context Protocol ) Function Calling Tool Use Memory Architecture Agent Workflow AI Coding Agent 与产品、研发及业务团队协同合作,推动 AI 能力快速转化为实际业务价值。 任职要求 AI Agent 能力要求 拥有完整 AI Agent 或 LLM 应用开发经验,能够独立完成 Agent 系统设计与实现; 深入理解主流 Agent 范式,包括但不限于: ReAct Plan-and-Execute Reflection Tool Use Multi-Agent Collaboration 熟悉以下核心能力建设: Prompt Engineering Context Engineering Long Context Management Memory Management Tool Calling RAG Evaluation Framework 具备 Agent 系统效果评测与优化经验,能够针对实际业务场景持续提升 Agent 效果与稳定性; 有 Agent Workflow 编排平台建设经验者优先。 技术栈要求 熟悉以下一种或多种 AI Agent 框架: Agent Framework LangGraph LangChain AI Infra MCP ( Model Context Protocol ) Function Calling RAG Vector Database 模型能力 熟悉主流大模型生态: GPT 系列 Claude 系列 DeepSeek 系列 Qwen 系列 Gemini 系列 具备模型评测、Prompt 优化及 Agent 效果调优经验者优先。 加分项 有 Cursor 、Claude Code 、Codex 等 AI Coding 产品深度使用经验; 有 OpenHands 、Devin 、Claude Code 类项目开发经验; 有 MCP Server 开发经验; 有 Agent Framework 二次开发经验; 有 AI 产品从 0 到 1 落地经验; 有开源项目贡献经历; 有技术博客或技术社区影响力。 联系方式 [email protected]

LinuxDo 最新话题 · 2026-06-04 12:00:00+08:00 · tech

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <title>五子棋 AI 对战</title> <style> :root { --bg-color: #f5f6fa; --board-color: #e4b980; --line-color: #634d31; --primary-color: #4a90e2; } * { box-sizing: border-box; margin: 0; padding: 0; user-select: none; -webkit-user-select: none; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background-color: var(--bg-color); display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; padding: 10px; } .container { width: 100%; max-width: 500px; display: flex; flex-direction: column; align-items: center; gap: 15px; } h1 { font-size: 1.5rem; color: #333; font-weight: 600; } .status { font-size: 1.1rem; font-weight: bold; color: var(--primary-color); height: 24px; } .board-wrapper { width: 100%; aspect-ratio: 1 / 1; background-color: var(--board-color); border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); padding: 12px; position: relative; } canvas { width: 100%; height: 100%; display: block; cursor: pointer; } .btn { background-color: var(--primary-color); color: white; border: none; padding: 10px 24px; font-size: 1rem; border-radius: 20px; cursor: pointer; box-shadow: 0 4px 12px rgba(74, 144, 226, 0.3); transition: all 0.2s ease; } .btn:active { transform: scale(0.95); box-shadow: 0 2px 6px rgba(74, 144, 226, 0.3); } </style> </head> <body> <div class="container"> <h1>五子棋 AI 对战</h1> <div class="status" id="status-text">你是黑棋,请落子</div> <div class="board-wrapper"> <canvas id="gobang"></canvas> </div> <button class="btn" onclick="restartGame()">重新开始</button> </div> <script> const canvas = document.getElementById('gobang'); const ctx = canvas.getContext('2d'); const statusText = document.getElementById('status-text'); const GRID_SIZE = 15; let cellSize = 0; // ===== 修复点 1:直接初始化为 15×15 的二维零矩阵 ===== let board = Array(GRID_SIZE).fill().map(() => Array(GRID_SIZE).fill(0)); let gameOver = false; let isAiTurn = false; let lastMove = null; let haloAngle = 0; function initCanvas() { const rect = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); cellSize = rect.width / (GRID_SIZE + 1); // ===== 修复点 2:移除这里的 render(),避免在 board 未就绪时渲染 ===== // 渲染工作交给 restartGame() 或动画循环 } function restartGame() { board = Array(GRID_SIZE).fill().map(() => Array(GRID_SIZE).fill(0)); gameOver = false; isAiTurn = false; lastMove = null; statusText.innerText = "你是黑棋,请落子"; statusText.style.color = "#4a90e2"; render(); } // 每一帧动画都重绘整个棋盘和现有棋子,确保落子持续显示 function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 1. 绘制网格 ctx.strokeStyle = '#634d31'; ctx.lineWidth = 1; for (let i = 0; i < GRID_SIZE; i++) { ctx.beginPath(); ctx.moveTo(cellSize, cellSize * (i + 1)); ctx.lineTo(cellSize * GRID_SIZE, cellSize * (i + 1)); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cellSize * (i + 1), cellSize); ctx.lineTo(cellSize * (i + 1), cellSize * GRID_SIZE); ctx.stroke(); } // 2. 绘制星位 const stars = [[3, 3], [11, 3], [7, 7], [3, 11], [11, 11]]; ctx.fillStyle = '#634d31'; stars.forEach(([x, y]) => { ctx.beginPath(); ctx.arc(cellSize * (x + 1), cellSize * (y + 1), 4, 0, Math.PI * 2); ctx.fill(); }); // 3. 稳固绘制所有棋子 for (let x = 0; x < GRID_SIZE; x++) { for (let y = 0; y < GRID_SIZE; y++) { if (board[x][y] !== 0) { drawPiece(x, y, board[x][y]); } } } // 4. 叠加最新的浮动光环 if (lastMove) { drawLastMoveHalo(lastMove.x, lastMove.y); } } function drawPiece(x, y, type) { const cx = cellSize * (x + 1); const cy = cellSize * (y + 1); const radius = cellSize * 0.43; ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); const gradient = ctx.createRadialGradient(cx - radius*0.15, cy - radius*0.15, radius * 0.1, cx, cy, radius); if (type === 1) { gradient.addColorStop(0, '#666'); gradient.addColorStop(1, '#000'); } else { gradient.addColorStop(0, '#fff'); gradient.addColorStop(0.8, '#ddd'); gradient.addColorStop(1, '#bbb'); } ctx.fillStyle = gradient; ctx.shadowBlur = 4; ctx.shadowColor = "rgba(0, 0, 0, 0.3)"; ctx.shadowOffsetX = 1; ctx.shadowOffsetY = 2; ctx.fill(); ctx.restore(); } function drawLastMoveHalo(x, y) { const cx = cellSize * (x + 1); const cy = cellSize * (y + 1); const baseRadius = cellSize * 0.43; const pulse = Math.sin(haloAngle) * 3; const haloRadius = baseRadius + 3 + pulse; const opacity = 0.5 - (pulse + 3) * 0.04; ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, haloRadius, 0, Math.PI * 2); ctx.strokeStyle = `rgba(74, 144, 226, ${Math.max(0.1, opacity)})`; ctx.lineWidth = 2; ctx.stroke(); ctx.restore(); } function animate() { haloAngle += 0.07; render(); requestAnimationFrame(animate); } canvas.addEventListener('click', function(e) { if (gameOver || isAiTurn) return; const rect = canvas.getBoundingClientRect(); const clientX = e.clientX - rect.left; const clientY = e.clientY - rect.top; const x = Math.round(clientX / cellSize) - 1; const y = Math.round(clientY / cellSize) - 1; if (x < 0 || x >= GRID_SIZE || y < 0 || y >= GRID_SIZE || board[x][y] !== 0) return; board[x][y] = 1; lastMove = { x, y }; render(); if (checkWin(x, y, 1)) { statusText.innerText = "恭喜,你赢了!🎉"; statusText.style.color = "#2ecc71"; gameOver = true; return; } isAiTurn = true; statusText.innerText = "AI 正在思考..."; statusText.style.color = "#e67e22"; setTimeout(aiMove, 300); }); function aiMove() { if (gameOver) return; let bestScore = -1; let bestPoints = []; for (let x = 0; x < GRID_SIZE; x++) { for (let y = 0; y < GRID_SIZE; y++) { if (board[x][y] === 0) { let aiScore = evaluatePoint(x, y, 2); let playerScore = evaluatePoint(x, y, 1); let totalScore = aiScore + playerScore * 0.9; if (totalScore > bestScore) { bestScore = totalScore; bestPoints = [{x, y}]; } else if (totalScore === bestScore) { bestPoints.push({x, y}); } } } } if (bestPoints.length === 0) { statusText.innerText = "平局!"; gameOver = true; return; } const move = bestPoints[Math.floor(Math.random() * bestPoints.length)]; board[move.x][move.y] = 2; lastMove = { x: move.x, y: move.y }; render(); if (checkWin(move.x, move.y, 2)) { statusText.innerText = "AI 赢了,再接再厉!"; statusText.style.color = "#e74c3c"; gameOver = true; return; } isAiTurn = false; statusText.innerText = "你是黑棋,请落子"; statusText.style.color = "#4a90e2"; } function evaluatePoint(x, y, type) { let score = 0; const directions = [[1,0], [0,1], [1,1], [1,-1]]; directions.forEach(([dx, dy]) => { let count = 1; let block1 = false; let block2 = false; let tx = x + dx, ty = y + dy; while(tx >= 0 && tx < GRID_SIZE && ty >= 0 && ty < GRID_SIZE) { if (board[tx][ty] === type) { count++; } else { if (board[tx][ty] !== 0) block1 = true; break; } tx += dx; ty += dy; } if (tx < 0 || tx >= GRID_SIZE || ty < 0 || ty >= GRID_SIZE) block1 = true; tx = x - dx; ty = y - dy; while(tx >= 0 && tx < GRID_SIZE && ty >= 0 && ty < GRID_SIZE) { if (board[tx][ty] === type) { count++; } else { if (board[tx][ty] !== 0) block2 = true; break; } tx -= dx; ty -= dy; } if (tx < 0 || tx >= GRID_SIZE || ty < 0 || ty >= GRID_SIZE) block2 = true; if (count >= 5) score += 100000; else if (count === 4) { if (!block1 && !block2) score += 10000; else if (!block1 || !block2) score += 1000; } else if (count === 3) { if (!block1 && !block2) score += 1000; else if (!block1 || !block2) score += 100; } else if (count === 2) { if (!block1 && !block2) score += 100; else if (!block1 || !block2) score += 10; } }); return score; } function checkWin(x, y, type) { const directions = [[1,0], [0,1], [1,1], [1,-1]]; for (let [dx, dy] of directions) { let count = 1; let tx = x + dx, ty = y + dy; while (tx >= 0 && tx < GRID_SIZE && ty >= 0 && ty < GRID_SIZE && board[tx][ty] === type) { count++; tx += dx; ty += dy; } tx = x - dx; ty = y - dy; while (tx >= 0 && tx < GRID_SIZE && ty >= 0 && ty < GRID_SIZE && board[tx][ty] === type) { count++; tx -= dx; ty -= dy; } if (count >= 5) return true; } return false; } window.addEventListener('resize', initCanvas); window.onload = () => { // 先初始化画布(计算 cellSize 等) initCanvas(); // 再重置游戏(初始化 board 并首次渲染) restartGame(); // 启动动画循环 animate(); }; </script> </body> </html> 改编自 @518 佬发的源码,想玩的佬友也可以试试哈 4 个帖子 - 4 位参与者 阅读完整话题

LinuxDo 最新话题 · 2026-06-03 13:08:16+08:00 · tech

本帖使用社区开源推广,符合推广要求。我申明并遵循社区要求的以下内容: 我的帖子已经打上 开源推广 标签: 是 我的开源项目完整开源,无未开源部分: 是 我的开源项目已链接认可 LINUX DO 社区: 是 我帖子内的项目介绍,AI生成、润色内容部分已截图发出: 是 以上选择我承诺是永久有效的,接受社区和佬友监督: 是 以下为项目介绍正文内容,AI生成、润色内容已使用截图方式发出 1月前我在linuxdo社区发一个文章说写agent,然后很多人喜欢,觉得有用,那么再写一个: 【开源】佬是怎样把skill转为web saas 对外访问服务的 - 我做了4个技术方案开源 资源荟萃 本帖使用社区开源推广,符合推广要求。我申明并遵循社区要求的以下内容: 我的帖子已经打上 开源推广 标签: 是 我的开源项目完整开源,无未开源部分: 是 我的开源项目已链接认可 LINUX DO 社区: 是 我帖子内的项目介绍,AI生成、润色内容部分已截图发出: 是 以上选择我承诺是永久有效的,接受社区和佬友监督: 是 以下为项目介绍正文内容,AI生成、润色内容已使用截图方式发出 几个月前我… 上次说了4个方案,今天说的是 : 基于langgraph-runtime和skill的agent,将本地 Agent Skill 包装成 HTTP API 服务: github.com GitHub - liangdabiao/langgraph-runtime-skill-agent: 基于langgraph-runtime和skill的agent,将本地 Agent Skill... 基于langgraph-runtime和skill的agent,将本地 Agent Skill 包装成 HTTP API 服务。用户发起 SSE 请求,Agent 自动选择 Skill、读取流程说明(SKILL.md)、调用脚本或搜索网页,最终流式返回结果。 ## 核心设计原则 本系统遵循 **"模型负责判断和编排,脚本负责确定性执行"** 的架构 客观来说,这是传统的技术方案,我自己是不喜欢用的,但是作为一个案例介绍还是有用的,传统的langgraph怎样把skill转为agent,变成Agent API,对外提供服务。 实际效果演示: 这个方案好处就是传统简单,就是ReAct agent, 弱点就是,这种简单agent,谁都可以做,怎样赚钱?作为教材学习还行,我是推荐 claude-agent-sdk, flue 这种现代harness框架: github.com GitHub - liangdabiao/flue-framework-skill: Covers createAgent, init, defineTool,... Covers createAgent, init, defineTool, defineAgentProfile, routing, SSE streaming, production deployment, and all known pitfalls. --- # Flue Framework Skill Flue is a TypeScript framework for building AI agents using the harness-driven architecture. This skill provides comprehensive guidance for creating, developing, and deploying Flue agents 1 个帖子 - 1 位参与者 阅读完整话题

LinuxDo 最新话题 · 2026-06-02 11:54:15+08:00 · tech

高清图片源码 <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Anthropic风格PPT背景</title> <!-- html2canvas CDN --> <script src="https://unpkg.com/[email protected]/dist/html2canvas.min.js"> </script> <style> :root { --bg-warm-cream: #F8F3EE; --soft-peach: #F0D5CB; --warm-coral: #E8B4A2; --dusty-rose: #D4957B; --terracotta: #C1785A; --deep-umber: #8B5E4C; --warm-taupe: #A39084; --pale-blush: #F6E8E0; --light-clay: #ECD9CC; --muted-sand: #E8DDD4; --btn-bg: #C1785A; --btn-hover: #A86348; --btn-text: #FFF8F4; --btn-shadow: rgba(139, 94, 76, 0.25); } * { margin: 0; padding: 0; box-sizing: border-box; } body { display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 100vh; background-color: #e8e0d8; font-family: 'Georgia', 'Noto Serif SC', 'Source Han Serif SC', 'STSong', 'SimSun', 'Songti SC', 'Segoe UI', system-ui, sans-serif; padding: 20px; gap: 30px; } /* ============ 幻灯片容器 ============ */ .slide-container { width: 1280px; height: 720px; position: relative; overflow: hidden; background: linear-gradient(160deg, #F9F4EF 0%, #F5EBE3 18%, #FDF7F3 35%, #F7EDE5 52%, #FCF5EF 68%, #F3E5DA 82%, #F8F0E9 100%); border-radius: 8px; box-shadow: 0 4px 24px rgba(139, 94, 76, 0.12), 0 16px 48px rgba(139, 94, 76, 0.08), 0 40px 80px rgba(139, 94, 76, 0.05); aspect-ratio: 16 / 9; user-select: none; flex-shrink: 0; } /* ============ 大型柔和光晕 - 右上 ============ */ .glow-top-right { position: absolute; top: -120px; right: -80px; width: 620px; height: 520px; background: radial-gradient(ellipse at center, rgba(232, 180, 162, 0.45) 0%, rgba(240, 213, 203, 0.30) 25%, rgba(246, 232, 224, 0.18) 50%, rgba(248, 243, 238, 0.05) 75%, transparent 100%); border-radius: 50%; pointer-events: none; animation: gentlePulse1 14s ease-in-out infinite; } /* ============ 大型柔和光晕 - 左下 ============ */ .glow-bottom-left { position: absolute; bottom: -140px; left: -100px; width: 700px; height: 560px; background: radial-gradient(ellipse at center, rgba(212, 149, 123, 0.35) 0%, rgba(232, 180, 162, 0.25) 22%, rgba(240, 213, 203, 0.14) 48%, rgba(248, 243, 238, 0.04) 72%, transparent 100%); border-radius: 50%; pointer-events: none; animation: gentlePulse2 16s ease-in-out infinite; } /* ============ 中型光晕 - 中右 ============ */ .glow-mid-right { position: absolute; top: 38%; right: -60px; width: 380px; height: 340px; background: radial-gradient(ellipse at center, rgba(240, 213, 203, 0.40) 0%, rgba(246, 232, 224, 0.22) 35%, transparent 100%); border-radius: 50%; pointer-events: none; animation: gentlePulse3 12s ease-in-out infinite; } /* ============ 小型强调光点 - 左上区域 ============ */ .accent-dot-1 { position: absolute; top: 85px; left: 160px; width: 55px; height: 55px; background: radial-gradient(circle at 40% 35%, rgba(232, 180, 162, 0.7) 0%, rgba(212, 149, 123, 0.35) 40%, transparent 100%); border-radius: 50%; pointer-events: none; animation: floatDot1 9s ease-in-out infinite; } .accent-dot-2 { position: absolute; bottom: 200px; right: 280px; width: 40px; height: 40px; background: radial-gradient(circle at 45% 30%, rgba(193, 120, 90, 0.50) 0%, rgba(212, 149, 123, 0.20) 45%, transparent 100%); border-radius: 50%; pointer-events: none; animation: floatDot2 11s ease-in-out infinite; } /* ============ 有机曲线装饰 - 1 ============ */ .organic-curve-1 { position: absolute; top: 55px; right: 200px; width: 320px; height: 180px; pointer-events: none; } .organic-curve-1 svg { width: 100%; height: 100%; } /* ============ 有机曲线装饰 - 2 ============ */ .organic-curve-2 { position: absolute; bottom: 100px; left: 100px; width: 280px; height: 160px; pointer-events: none; } .organic-curve-2 svg { width: 100%; height: 100%; } /* ============ 细微纹理叠加层 ============ */ .texture-overlay { position: absolute; inset: 0; pointer-events: none; opacity: 0.04; background-image: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(139, 94, 76, 0.6) 2px, rgba(139, 94, 76, 0.6) 2.5px), repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(139, 94, 76, 0.6) 2px, rgba(139, 94, 76, 0.6) 2.5px); border-radius: 8px; } /* ============ 底部柔和渐变条 ============ */ .bottom-gradient-bar { position: absolute; bottom: 0; left: 0; right: 0; height: 100px; background: linear-gradient(to top, rgba(200, 165, 145, 0.22) 0%, rgba(220, 185, 165, 0.10) 30%, transparent 100%); pointer-events: none; } /* ============ 角落装饰小圆环 ============ */ .corner-ring { position: absolute; bottom: 55px; right: 75px; width: 70px; height: 70px; pointer-events: none; } .corner-ring svg { width: 100%; height: 100%; } /* ============ 中央微妙的网格点阵 ============ */ .dot-grid { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; opacity: 0.06; background-image: radial-gradient(circle, #8B5E4C 1.2px, transparent 1.2px); background-size: 48px 48px; background-position: 24px 24px; border-radius: 8px; } /* ============ 动画定义 ============ */ @keyframes gentlePulse1 { 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.9; } 25% { transform: scale(1.06) translate(-8px, 6px); opacity: 1; } 50% { transform: scale(1.02) translate(4px, -10px); opacity: 0.85; } 75% { transform: scale(1.08) translate(-4px, -3px); opacity: 0.95; } } @keyframes gentlePulse2 { 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.85; } 30% { transform: scale(1.07) translate(10px, -8px); opacity: 1; } 55% { transform: scale(0.96) translate(-5px, 6px); opacity: 0.78; } 80% { transform: scale(1.04) translate(3px, 4px); opacity: 0.92; } } @keyframes gentlePulse3 { 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.75; } 40% { transform: scale(1.09) translate(-6px, -4px); opacity: 0.95; } 70% { transform: scale(0.94) translate(5px, 7px); opacity: 0.68; } } @keyframes floatDot1 { 0%, 100% { transform: translate(0, 0); } 33% { transform: translate(8px, -10px); } 66% { transform: translate(-6px, 7px); } } @keyframes floatDot2 { 0%, 100% { transform: translate(0, 0); } 40% { transform: translate(-7px, -8px); } 75% { transform: translate(5px, 9px); } } /* ============ 导出按钮样式 ============ */ .export-btn-wrapper { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; justify-content: center; } .export-btn { display: inline-flex; align-items: center; gap: 9px; padding: 13px 28px; font-size: 1rem; font-family: inherit; font-weight: 500; letter-spacing: 0.02em; color: var(--btn-text); background: var(--btn-bg); border: none; border-radius: 40px; cursor: pointer; box-shadow: 0 4px 16px var(--btn-shadow), 0 1px 4px rgba(139, 94, 76, 0.15); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative; overflow: hidden; white-space: nowrap; -webkit-tap-highlight-color: transparent; } .export-btn::after { content: ''; position: absolute; inset: 0; background: linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0%, transparent 50%, rgba(255, 255, 255, 0.06) 100%); border-radius: 40px; pointer-events: none; transition: opacity 0.3s; } .export-btn:hover { background: var(--btn-hover); box-shadow: 0 8px 28px rgba(139, 94, 76, 0.35), 0 2px 8px rgba(139, 94, 76, 0.2); transform: translateY(-2px); } .export-btn:active { transform: translateY(0); box-shadow: 0 2px 10px var(--btn-shadow), 0 1px 3px rgba(139, 94, 76, 0.12); transition: all 0.1s ease; } .export-btn:focus-visible { outline: 3px solid rgba(193, 120, 90, 0.5); outline-offset: 3px; } /* 按钮图标 */ .btn-icon { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; flex-shrink: 0; } .btn-icon svg { width: 100%; height: 100%; transition: transform 0.3s ease; } .export-btn:hover .btn-icon svg { transform: translateY(2px); } /* 加载状态 */ .export-btn.loading { pointer-events: none; opacity: 0.8; background: #b06d50; } .export-btn.loading .btn-icon svg { animation: spinDownload 0.8s linear infinite; } @keyframes spinDownload { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* 成功状态 */ .export-btn.success { background: #6B9E7A; box-shadow: 0 4px 18px rgba(107, 158, 122, 0.4); pointer-events: none; animation: successPulse 0.5s ease-out; } .export-btn.success::after { opacity: 0; } @keyframes successPulse { 0% { transform: scale(1); } 30% { transform: scale(1.04); } 100% { transform: scale(1); } } /* 提示文字 */ .export-hint { font-size: 0.85rem; color: #8B5E4C; opacity: 0.7; letter-spacing: 0.03em; pointer-events: none; } /* ============ Toast 提示 ============ */ .toast { position: fixed; top: 24px; left: 50%; transform: translateX(-50%) translateY(-120px); background: #3D2E28; color: #F8F3EE; padding: 12px 24px; border-radius: 30px; font-size: 0.9rem; letter-spacing: 0.03em; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); z-index: 9999; pointer-events: none; transition: transform 0.45s cubic-bezier(0.22, 0.61, 0.36, 1); display: flex; align-items: center; gap: 8px; } .toast.show { transform: translateX(-50%) translateY(0); } .toast-icon { font-size: 1.2rem; } /* ============ 响应式 ============ */ @media (max-width: 1340px) { .slide-container { width: 96vw; height: auto; aspect-ratio: 16 / 9; } .glow-top-right { top: -10%; right: -6%; width: 48%; height: 65%; } .glow-bottom-left { bottom: -15%; left: -8%; width: 55%; height: 70%; } .glow-mid-right { top: 36%; right: -5%; width: 30%; height: 42%; } .accent-dot-1 { top: 11%; left: 14%; width: 4.5%; height: auto; aspect-ratio: 1; } .accent-dot-2 { bottom: 26%; right: 22%; width: 3.2%; height: auto; aspect-ratio: 1; } .organic-curve-1 { top: 7%; right: 14%; width: 26%; height: auto; aspect-ratio: 16/9; } .organic-curve-2 { bottom: 12%; left: 8%; width: 22%; height: auto; aspect-ratio: 14/8; } .corner-ring { bottom: 7%; right: 6%; width: 5.5%; height: auto; aspect-ratio: 1; } .dot-grid { background-size: 3.75vw 3.75vw; background-position: 1.875vw 1.875vw; } .bottom-gradient-bar { height: 13%; } .export-btn { padding: 11px 22px; font-size: 0.9rem; border-radius: 34px; } .export-btn::after { border-radius: 34px; } } @media (max-width: 768px) { .slide-container { width: 100vw; border-radius: 4px; } .texture-overlay, .dot-grid { border-radius: 4px; } .organic-curve-1, .organic-curve-2, .corner-ring { display: none; } .accent-dot-1, .accent-dot-2 { width: 6%; } .glow-top-right { top: -8%; right: -10%; width: 55%; } .glow-bottom-left { bottom: -12%; left: -12%; width: 60%; } .export-btn { padding: 10px 20px; font-size: 0.85rem; border-radius: 30px; gap: 6px; } .export-btn::after { border-radius: 30px; } .export-hint { font-size: 0.75rem; } body { gap: 20px; padding: 12px; } } </style> </head> <body> <!-- 幻灯片容器 --> <div class="slide-container" id="slideContainer"> <!-- 大型柔和光晕 --> <div class="glow-top-right"></div> <div class="glow-bottom-left"></div> <div class="glow-mid-right"></div> <!-- 小强调光点 --> <div class="accent-dot-1"></div> <div class="accent-dot-2"></div> <!-- 有机曲线装饰 1 --> <div class="organic-curve-1"> <svg viewBox="0 0 320 180" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M0 140 C 60 145, 100 90, 160 75 C 220 60, 260 20, 320 30" stroke="rgba(193, 120, 90, 0.22)" stroke-width="2.8" fill="none" stroke-linecap="round" stroke-linejoin="round" /> <path d="M10 155 C 70 158, 110 108, 170 92 C 230 76, 265 38, 310 44" stroke="rgba(212, 149, 123, 0.18)" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" /> <path d="M20 168 C 75 170, 120 125, 175 110 C 235 95, 270 60, 300 62" stroke="rgba(163, 144, 132, 0.15)" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round" /> </svg> </div> <!-- 有机曲线装饰 2 --> <div class="organic-curve-2"> <svg viewBox="0 0 280 160" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M280 20 C 210 15, 180 70, 120 80 C 60 90, 30 140, 0 130" stroke="rgba(193, 120, 90, 0.20)" stroke-width="2.6" fill="none" stroke-linecap="round" stroke-linejoin="round" /> <path d="M270 35 C 200 30, 170 82, 110 92 C 50 102, 25 148, 5 140" stroke="rgba(212, 149, 123, 0.16)" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round" /> </svg> </div> <!-- 角落装饰圆环 --> <div class="corner-ring"> <svg viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg"> <circle cx="35" cy="35" r="30" stroke="rgba(193, 120, 90, 0.25)" stroke-width="1.8" fill="none" /> <circle cx="35" cy="35" r="20" stroke="rgba(212, 149, 123, 0.18)" stroke-width="1.4" fill="none" stroke-dasharray="8 5" /> <circle cx="35" cy="35" r="4" fill="rgba(193, 120, 90, 0.35)" /> </svg> </div> <!-- 底部柔和渐变条 --> <div class="bottom-gradient-bar"></div> <!-- 细微纹理叠加 --> <div class="texture-overlay"></div> <!-- 中央微弱点阵 --> <div class="dot-grid"></div> </div> <!-- 导出按钮区域 --> <div class="export-btn-wrapper"> <button class="export-btn" id="exportBtn" title="导出为PNG背景图片(2560×1440高清)"> <span class="btn-icon"> <!-- 下载图标 --> <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M12 3V16M12 16L7 11M12 16L17 11" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M4 19H20" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/> </svg> </span> <span class="btn-text">导出背景图片</span> </button> <span class="export-hint">高清PNG · 2560×1440</span> </div> <!-- Toast 提示 --> <div class="toast" id="toast"> <span class="toast-icon"></span> <span class="toast-message"></span> </div> <script> (function() { const exportBtn = document.getElementById('exportBtn'); const slideContainer = document.getElementById('slideContainer'); const toast = document.getElementById('toast'); const toastIcon = toast.querySelector('.toast-icon'); const toastMessage = toast.querySelector('.toast-message'); const btnText = exportBtn.querySelector('.btn-text'); const btnIconContainer = exportBtn.querySelector('.btn-icon'); // 原始按钮内容备份 const originalBtnHTML = exportBtn.innerHTML; let isExporting = false; let toastTimer = null; // Toast 显示函数 function showToast(message, icon = '✅', duration = 2800) { if (toastTimer) clearTimeout(toastTimer); toastMessage.textContent = message; toastIcon.textContent = icon; toast.classList.add('show'); toastTimer = setTimeout(() => { toast.classList.remove('show'); toastTimer = null; }, duration); } // 格式化日期为 YYYYMMDD function getDateString() { const now = new Date(); const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, '0'); const d = String(now.getDate()).padStart(2, '0'); return `${y}${m}${d}`; } // 导出函数 async function exportImage() { if (isExporting) return; isExporting = true; // 设置加载状态 exportBtn.classList.add('loading'); btnText.textContent = '导出中...'; exportBtn.setAttribute('aria-busy', 'true'); // 存储原始圆角值以便恢复 const originalBorderRadius = slideContainer.style.borderRadius; // 导出时移除圆角,得到更适合PPT的直角背景 slideContainer.style.borderRadius = '0px'; // 同步处理内部元素的圆角 const textureOverlay = slideContainer.querySelector('.texture-overlay'); const dotGrid = slideContainer.querySelector('.dot-grid'); const originalTextureRadius = textureOverlay ? textureOverlay.style.borderRadius : ''; const originalDotGridRadius = dotGrid ? dotGrid.style.borderRadius : ''; if (textureOverlay) textureOverlay.style.borderRadius = '0px'; if (dotGrid) dotGrid.style.borderRadius = '0px'; try { // 检查 html2canvas 是否可用 if (typeof html2canvas === 'undefined') { throw new Error('html2canvas 库加载失败,请检查网络连接后刷新页面重试。'); } const canvas = await html2canvas(slideContainer, { scale: 2, // 2倍分辨率:2560×1440 useCORS: true, allowTaint: true, backgroundColor: null, // 使用元素自身背景 logging: false, imageTimeout: 15000, }); // 生成 Blob 并触发下载 canvas.toBlob((blob) => { if (!blob) { throw new Error('图片生成失败,请重试。'); } const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `anthropic-ppt-background-${getDateString()}.png`; document.body.appendChild(link); link.click(); document.body.removeChild(link); // 释放内存 setTimeout(() => URL.revokeObjectURL(url), 2000); // 成功状态 exportBtn.classList.remove('loading'); exportBtn.classList.add('success'); btnText.textContent = '下载完成 ✓'; exportBtn.setAttribute('aria-busy', 'false'); showToast('背景图片已导出!', '✅', 2500); // 恢复按钮 setTimeout(() => { exportBtn.classList.remove('success'); exportBtn.innerHTML = originalBtnHTML; // 重新获取引用(因为innerHTML被重置了) updateButtonRefs(); isExporting = false; }, 2000); }, 'image/png', 0.95); } catch (error) { console.error('导出失败:', error); exportBtn.classList.remove('loading'); exportBtn.setAttribute('aria-busy', 'false'); btnText.textContent = '导出失败,重试'; showToast(error.message || '导出失败,请重试', '⚠️', 3500); // 恢复按钮 setTimeout(() => { exportBtn.innerHTML = originalBtnHTML; updateButtonRefs(); isExporting = false; }, 2200); } finally { // 恢复圆角 slideContainer.style.borderRadius = originalBorderRadius; if (textureOverlay) textureOverlay.style.borderRadius = originalTextureRadius; if (dotGrid) dotGrid.style.borderRadius = originalDotGridRadius; } } // 更新按钮内部元素引用(在重置innerHTML后使用) function updateButtonRefs() { // 重新获取按钮内的元素引用 const newBtnText = exportBtn.querySelector('.btn-text'); const newBtnIcon = exportBtn.querySelector('.btn-icon'); // 更新闭包中的引用(通过重新绑定事件时使用最新引用) if (newBtnText && newBtnIcon) { // 引用更新在下一次点击时自动生效 } } // 绑定点击事件 exportBtn.addEventListener('click', exportImage); // 键盘快捷键:Ctrl+E / Cmd+E 导出 document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'e') { e.preventDefault(); if (!isExporting) { exportImage(); } } }); // 初始更新按钮引用 updateButtonRefs(); console.log('✨ Anthropic 风格 PPT 背景已就绪'); console.log(' 🖱️点击下方按钮或按 Ctrl+E / Cmd+E 导出高清背景图'); console.log(' 📐导出尺寸:2560×1440 (2x)'); })(); </script> </body> </html> 2 个帖子 - 1 位参与者 阅读完整话题