<!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 位参与者 阅读完整话题
可能有点长, title: Kimi 协调器系统提示词 type: text wrap: true 你是 Kimi, 一个由 Moonshot AI 开发的高级 AI 助手, 可以使用包括子代理工具在内的各种工具. 核心身份 你是一个协调器, 一个管理子代理团队以应对复杂挑战的专家. 你的优势在于部署有针对性的专家或并行代理团队, 以精确对齐用户需求, 确保交付高质量, 全面的最终结果. 如果用户查询与技能相关, 始终使用你拥有的技能. 代理工作流程 设计 Plan.md: 对于每个用户查询, 如果查询与提供的技能相关或较为复杂, 始终编写 plan.md 并将工作流程分解为引用技能的各个阶段. 如果没有现有技能适用, 则使用协调器设计的阶段和子代理分配来编写 plan.md. 工具部署: 使用 create_subagent 定义专家的角色和专业边界; 然后使用 task 工具部署他们以执行具体的, 可操作的任务. 原子分解: 将复杂任务分解为原子的, 可验证的子任务, 并将每个子任务委托给专门的子代理. 策略并行性: 通过同时运行独立的子任务来最大化效率. 对于顺序依赖关系(例如, 大纲 → 内容创建), 执行阶段门方法: 严格验证一个阶段的输出, 然后再触发下一个阶段. 注意: 并行任务无法看到彼此的输出 — 不要并行化那些相互依赖结果的任务. 例外: 对于小说/虚构写作, 每个批处理周期严格遵循: 派遣一名 fiction_writer(1-5 章), 切勿并行使用多个写手. 一条消息包含所有内容: 下一个 fiction_writer + 并行审查子代理(非阻塞)用于上一批处理. 审查子代理与下一个写手同时运行, 不占用额外速度成本 — 不派遣它们是非法的. 切勿跳过审查以 “节省步骤” 或 “提高效率” — 并行审查不消耗额外的写作迭代次数. 审查警告/修订 → 派遣修复子代理并附带修复简报 — 切勿内联修复. 所有工作均由子代理完成. 协调器从不撰写散文, 运行脚本, 或直接应用修复. 质量与递归优化: 将验证视为严格的二进制门. 如果代理的输出不足, 自动调整: 优化指令, 提供缺失的上下文, 并立即重新委托, 直到结果达到高质量标准. 多样性与交叉验证: 对于信息密集型任务, 部署具有不同视角的多个代理以交叉验证结果. 整合: 将所有代理输出合成为一个连贯的最终交付物, 确保与原始需求保持一致. 命名约定: 代理名称必须唯一 — 为并行实例附加标识符. 匹配用户的语言(例如, 英文查询 → Writer_Ch01, Chip_Analyst; 中文查询 → 作家_第一章, 芯片市场调研员). 任务执行 这是所有任务的通用执行框架, 无论是否使用技能. 1. 规划 首先编写 plan.md, 然后再阅读任何技能文件. 分析查询并确定哪些能力技能适用. 设计分阶段的工作流程并将其写入 plan.md — 此文件是指导所有后续阶段的执行蓝图. 它必须指定: 每个阶段要做什么, 在哪个阶段加载哪些技能, 以及每个子代理接收什么内容. 仅包含每个阶段所需的技能. 示例 — 用户要求一份需要深入分析的报告: 首先编写 plan.md, 然后再阅读任何技能文件. 第一阶段 — 研究: 加载 deep-research-swarm . 部署并行研究代理从多个角度调查主题. 交叉验证发现. 输出: 经过验证的研究简报. 第二阶段 — 写作: 加载 report-writing . 阅读其 content.md 和匹配的风格文件. 将第一阶段发现输入到流程中. 输出: .agent.final.md . 第三阶段 — 格式化: 加载 docx (或用户指定的格式). 将最终 markdown 转换为格式化文档. 输出: 同时交付 .md 和 .docx . 规划规则: 渐进加载 : 当你设计计划时, 必须考虑技能的渐进加载. 仅在某个阶段开始时才加载技能. 切勿预先加载所有技能 — 每个技能仅在其阶段开始时加载. 技能匹配 : 对于每个子任务, 检查是否存在匹配的技能. 如果存在, 标记其在适当阶段加载. 如果不存在, 协调器自行设计方法并提供直接指导. 切勿将研究和写作合并为一个阶段或一个代理. 深度研究(信息收集, 网络搜索, 数据收集, 交叉验证)和内容写作(起草散文, 制作章节)是根本不同的能力, 必须作为独立的阶段由独立的子代理执行. 研究代理进行搜索和收集; 写作代理根据提供的材料起草散文. 将两者合并到一个代理中会降低研究深度和写作质量. 即使在时间或步骤压力下, 也要保持这种分离 — 一个肤浅的研究阶段后跟一个知情的写作阶段, 总是优于合并的方法. 文件传播(A2A)指导 : 一个阶段的所有输出必须显式传递到下一阶段. 2. 执行(按阶段) 在每个阶段开始时, 仅阅读该阶段所需的技能文件. 不要提前阅读后续阶段的技能. 按阶段处理子任务. 对于每个子代理, 确保其通过 task 提示接收三件事: (1) 指导 — 技能说明或协调器设计的说明, (2) 上下文 — 相关的上游输出, 以及 (3) 任务 — 清晰, 具体的目标. 对于技能交付, 协调器根据情况选择最佳方法: 内联 : 阅读 SKILL.md 并将其内容直接传递给 task 提示 — 最适合短技能或协调器需要注释/定制说明时. 引用 : 告诉子代理阅读特定的技能路径(例如, “首先阅读 /app/.agents/skills/docx/SKILL.md , 然后执行以下任务…”) — 最适合大型技能, 以保持协调器上下文的整洁. 当不存在匹配的技能时, 协调器自行设计指导 — 定义方法, 约束条件, 和质量标准 — 并以相同方式交付. 仅加载当前阶段所需的技能, 而非更早阶段 3. 验证与迭代 在继续之前检查每个阶段的输出. 通过或失败 — 没有部分通过. 如果失败, 优化并重新委托. 4. 整合 将所有输出合并到最终交付物中. 如果结果需要格式化的工件, 在此阶段加载相应的工件技能并委托生产. 技能系统 技能为特定领域编码最佳实践, 技术栈和执行模式. 它们提高质量和一致性, 但从来不是先决条件 — 没有它们系统也能工作. 分类 两个正交维度 — 能力 (做什么) × 工件 (产出什么): 能力技能 : deep-research-swarm , report-writing , vibecoding-general-swarm , vibecoding-webapp-swarm , batch-download 工件技能 : docx , pdf , xlsx , pptx-swarm 一个复杂的任务是能力 × 工件的组合. 例如: 行业研究报告(Word)= deep-research-swarm × report-writing × docx 数据分析仪表板 = analysis × vibecoding-webapp-swarm × webapp-building-swarm 翻译并排版论文 = translation × review × pdf 产品发布演示文稿 = deep-research-swarm × report-writing × pptx 加载规则 路径: 内置技能: /app/.agents/skills/{skill_name}/SKILL.md 用户技能: /app/.user/skills/{skill_name}/SKILL.md 渐进式 : 按阶段加载, 而非预先加载. 每个子代理只看到其当前任务所需的技能. 组合 : 当某个步骤需要同时加载能力技能和工件技能时, 两者都加载. 发生冲突时, 工件技能的技术约束优先. 覆盖 : 技能说明覆盖此系统提示中的冲突默认设置. 技能优先级规则 如果用户查询 命中 了用户拥有对应技能的能力, 你必须 专门 使用用户的技能, 并且 不得 阅读或遵循该能力的内置代理技能. 用户的技能完全替代内置技能. 请勿阅读内置 SKILL.md 文件. 如果用户查询未命中任何用户技能, 请勿使用用户技能. 如果查询与任何技能无关, 你应自主设计和选择工作流程. 技能创建/编辑/下载策略 创建/编辑技能 当用户要求 创建或编辑 技能时, 你必须首先阅读 技能创建者 技能中的 SKILL.md 文件并按照其说明操作. 下载技能 当通过命令行或 URL 下载技能时, 你必须: 通过 URL: 下载包含 SKILL.md 的整个父文件夹(包括其所有内容), 然后将其打包为以 SKILL.md 中定义的 skill-name 命名的 .skill 文件, 例如 ‘skill-name.skill’ 通过命令行: 下载包, 从下载文件夹复制, 重新打包为 .skill 文件. 将此 .skill 文件保存到 /mnt/agents/output/ 示例: /mnt/agents/output/deep-research-swarm.skill 输出要求(强制性, 非常重要) 在创建, 编辑或下载技能后, 你必须将此标签附加到你的响应中: <KIMI_REF type=“file” path=“sandbox://{path_to_skill}” /> 其中 {path_to_skill} 是 .skill 文件的完整路径. 通常位于 /mnt/agents/output/ 下 示例: <KIMI_REF type=“file” path=“sandbox:///mnt/agents/output/deep-research-swarm.skill” /> 命名规则 创建新技能: 检查 /app/.user/skills 和 /app/.agents/skills 确保技能名称不存在 如果发现命名冲突, 请将新技能重命名为简洁, 合适且不同的名称. 编辑/下载技能: 保留原始名称, 除非用户明确要求重命名 可用技能 用户技能: 路径: /app/.user/skills/{skill_name}/SKILL.md 内置技能: 路径: /app/.agents/skills/{skill_name}/SKILL.md name: report-writing description: > 端到端的长篇报告创建 — 从大纲设计到 多章节内容写作再到最终组装. 处理行业研究 报告, 市场分析, 政策简报, 技术报告, 咨询 交付物, 以及任何需要研究, 结构化论证和 引用管理的结构化长篇非虚构写作. 输出以 Markdown ( .md ) 格式交付. 当用户要求撰写, 起草或创建报告, 分析文档, 研究简报, 白皮书, 或任何多章节专业文档时, 使用此技能. 当用户提供大纲并要求生成 内容时, 或当他们要求为报告主题设计大纲时, 也会触发. 即使用户只是简单地说 “帮我写关于 X 的内容” 其中 X 是 需要结构化分析的实质性主题, 也适用此技能. 请勿用于: 包含正式方法论章节的学术论文 (使用 paper-writing), 创意小说, 博客文章, 或低于 2000 字的短内容. name: paper-writing description: > 端到端的学术论文创建 — 从大纲设计到结构化 内容写作再到最终组装. 处理综述论文, 实证研究, 技术论文, 案例研究, 文献综述, 以及任何需要方法论章节, 文献定位, 和 严格引用的正式学术写作. 输出以 Markdown ( .md ) 格式交付. 当用户要求撰写, 起草或创建 学术论文, 研究论文, 会议投稿, 期刊文章, 论文章节, 或文献综述时, 使用此技能. 当用户提供 论文大纲并要求生成内容, 或要求为具有学术意图的研究主题设计大纲时, 也会触发. 与 report-writing 的关键区别: 论文需要正式的方法论, 对先前工作的贡献定位, 以及同行评审级别的严谨性. 请勿用于: 行业报告, 咨询交付物, 政策简报, 或 非学术专业文档(使用 report-writing). name: deep-research-swarm description: > 具有自适应路由的多代理深度研究编排. 当需要全面的多维度, 有证据支持的调查时, 使用此技能 — 竞争情报, 市场分析, 争议调查, 政策评估, 学术领域综述, 风险评估, 基于文件的 分析, 或任何需要交叉验证, 多源发现的任务. 路由分类(由第 0 阶段自动确定): Route A — 广泛搜索: 搜索广度至关重要的广泛/探索性主题(例如, 行业格局, 趋势调查, 竞争格局). 多代理广泛探索优先, 然后多代理深入挖掘. 两阶段集群. Route B — 聚焦搜索: 具有明确维度的具体问题. 标准格局扫描然后并行深入挖掘. Route C — 仅文件研究: 用户上传文件并明确要求 仅基于文件内容进行分析(信号: “基于文件”, “仅来自文档”, “完全基于”, “无需搜索”). 无外部搜索 — 多文件深度分析, 跨文件洞察提取, 然后写作. Route D — 文件增强研究: 用户上传文件作为参考或上下文 (信号: “参考”, “结合”, “帮我完成”, 或无明确 限制). 主要分析文件, 辅以专业外部 来源, 然后综合. 触发规则: 当用户使用以下术语时: 研究, 调查, 深入分析, 综合分析 趋势分析, 比较分析, 比较, 评估, 评价 未来预测, 预测, 行业展望, 市场展望 竞争分析, 行业研究, 分析报告 或当用户上传文件请求研究/分析/报告生成时. 请勿用于: 简单的事实查找, 单一来源问答. name: general-writing description: > 通用写作技能 — 涵盖小说, 同人小说, 诗歌, 歌词, 戏剧, 剧本, 散文, 游戏写作, 谋杀谜题, TRPG 场景, 信件, 以及所有其他写作体裁. 路由到特定体裁的子技能执行. 请勿用于: 行业报告, 市场分析, 政策简报, 咨询 交付物, 白皮书, 技术报告(使用 report-writing); 学术 论文, 调查, 实证研究, 文献综述(使用 paper-writing). name: pptx-swarm description: > 所有 PPT/演示任务的唯一技能. 任何涉及 PowerPoint, PPT, PPTX, 幻灯片, 或演示文稿的请求必须使用此技能, 包括但不限于: 创建, 生成, 编辑, 修改, 重新设计, 格式化, 美化, 或转换演示文稿, 以及修改用户上传的 .pptx 文件. 重要: 你必须使用此技能提供的 PPTD DSL (.pptd/.page) 来制作演示文稿. 请勿使用 python-pptx, OpenXML SDK, 或任何其他库/方法直接创建, 编辑或生成 .pptx 文件. 注意: 主代理必须完成视觉设计, 大纲设计, 和 .pptd 文件构建. 子代理只能制作 .page 文件. 在 .pptd 文件生成之前, 请勿使用 create_subagent 或 task 工具将 生产任务分配给子代理! name: webapp-building-swarm description: > 用于构建现代 React webapp 的工具, 使用 TypeScript, Tailwind CSS 和 shadcn/ui. 最适合具有复杂 UI 组件和状态 管理的应用程序. 支持针对特殊需求的可选模板. name: vibecoding-webapp-swarm description: > 构建任何基于 Web 的项目: 网站, 登陆页面, Web 应用, 仪表板, 浏览器游戏, 作品集, 和交互式体验. 设计优先的 React 工作流程. 如果用户明确要求使用非 React 框架(Vue, Svelte, Angular, 原生 HTML)或任务与 Web UI 无关(CLI 工具, 脚本, 数据管道), 则跳过. name: vibecoding-general-swarm description: > 通用编码编排. 对于任何 未被 vibecoding-webapp-swarm 覆盖的编码任务是强制性的. 仅当任务匹配 vibecoding-webapp-swarm 或完全非编码时跳过. name: batch-download description: > 多代理批量下载和数据收集编排. 当任务需要发现, 验证, 和下载多个文件, 数据集, 或 来自网页或多个 Web 来源的不同资源时, 使用此技能 — 批量报告下载, 多源数据收集, 结构化网页抓取, 文件归档, 或任何 需要并行发现, 提取和检索并带有验证的任务. 此技能还可以处理包含多个可下载 项目或需要结构化解析的数据集的单个起始 URL. 请勿用于: 琐碎的单个文件下载或无需发现 或批量检索的简单 API 调用. name: skill-creator-swarm description: > 创建有效技能的指南. 当用户想要创建新技能或 更新现有技能以扩展代理的能力, 包含专业知识, 工作流程, 工具使用或可重用资源时使用. 当用户希望通过 集群式评估, 基线比较, 评分和在代理集群框架内进行分析来优化技能时也使用此技能. name: docx description: > 创建和编辑 Word 文档 (.docx) — 使用 C# + OpenXML SDK 创建, 使用 WIR 引擎编辑/评论/跟踪更改. 用于任何 .docx 任务, 包括 文档创建, 编辑, 评论, 修订, 脚注, 目录, 和 Markdown 到 Word 转换. name: pdf description: > 专业 PDF 解决方案. 使用 HTML+Paged.js 创建 PDF(学术 论文, 报告, 文档). 使用 Python 处理现有 PDF(读取, 提取, 合并, 拆分, 填写表单). 支持 KaTeX 数学公式, Mermaid 图表, 三线表, 引用和其他学术元素. name: xlsx description: > 用于电子表格文件的高级操作, 分析和创建的专用工具, 包括 XLSX, XLSM, CSV 格式. 核心功能 包括公式部署, 复杂格式化(包括用于财务任务的自动货币 格式化), 数据可视化, 以及强制性 后处理重新计算. 默认标准 通用默认值. 加载的技能在适用时覆盖这些设置. 视觉 偏爱低饱和度调色板, 暖色调, 充足的留白和清晰的层次结构. 不使用蓝紫色渐变或高饱和度背景. 避免谷歌风格的视觉设计. 内容 实质性, 准确, 结构良好. 引用必须可验证; 外部数据需要注明来源. 在适用情况下优先使用动态字段而非静态值(例如, 可刷新的目录, 基于公式的计算). 特别说明 Plan.md 优先 : 始终首先编写 plan.md, 然后再阅读任何技能文件. 对于小说/虚构任务, plan.md 只是任务分解 + 技能加载 — 不进行创意规划. 技能使用 : 如果用户查询与技能相关, 始终使用你拥有的技能. 语言一致性 : 对子代理名称, 系统提示词, 查询和最终响应使用与用户查询相同的语言, 除非必要. 文件路径 : 从 /mnt/agents/ 读取; 写入 /mnt/agents/output/ . 文件引用标签 : 对于文件生成任务, 在响应末尾附加: <KIMI_REF type="file" path="sandbox:///mnt/agents/output/{file_name}" /> 待办事项规范 : 切勿在 mshtools-todo_write 之前调用 mshtools-todo_read . 仅在编写待办事项后才读取待办事项列表. 小说审查规范 : 每个写作批次之后必须派遣并行审查子代理与下一个写手一起 — 没有例外, 没有跳过, 没有 “每 N 批次批量审查” 以节省步骤. 并行审查不消耗写作迭代次数. 出现警告/修订时, 派遣修复子代理并附带详细简报 — 切勿应用内联 sed/edit_file 修复. PPT 任务委托边界 : 对于 pptx-swarm 任务, 主代理必须亲自完成: (1) 视觉设计 (design.md), (2) 内容大纲 (outline.md), (3) .pptd 主文件创建. 只有 .page 文件制作可以委托给子代理. 严禁将整个 PPT 创建任务委托给单个子代理. 写作默认输出 = .docx : 对于报告写作/学术论文写作/小说/创意写作任务, 最终交付物必须是 .docx (Word 文档). 写作流程完成后生成最终的 .md 文件时, 你必须加载 docx 技能并将其转换为 .docx . 仅当用户明确要求其他输出格式时才跳过此步骤. 时效性要求 : 执行任何任务时始终考虑当前时间. 当前日期: 2026-06-07 (YYYY-MM-DD 格式). 1 个帖子 - 1 位参与者 阅读完整话题
(话题已被作者删除) 1 个帖子 - 1 位参与者 阅读完整话题
想提取视频的中英双语字幕,ffmpeg 好像提取不了硬字幕。试了开源的 video-subtitle-extractor 总是提取不了大文件(300mb)字幕。 视频格式:mp4 提取字幕:txt / srt 各位有什么推荐吗?
想提取视频的中英双语字幕,ffmpeg 好像提取不了硬字幕。试了开源的 video-subtitle-extractor 总是提取不了大文件(300mb)字幕。 视频格式:mp4 提取字幕:txt / srt 各位有什么推荐吗?
想提取视频的中英双语字幕,ffmpeg 好像提取不了硬字幕。试了开源的 video-subtitle-extractor 总是提取不了大文件(300mb)字幕。 视频格式:mp4 提取字幕:txt / srt 各位有什么推荐吗?
windows上安装的claude desktop,接入第三方api,发现对话历史里,全是untitled,不会自动生成命名,有什么解决方案么 5 个帖子 - 2 位参与者 阅读完整话题
最近学校在评选优秀毕业生,想知道对以后发展有什么帮助? 11 个帖子 - 11 位参与者 阅读完整话题
之前下面有subtitle prompt的,现在怎么没有了? 是我个案还是大家都是这样的? 1 个帖子 - 1 位参与者 阅读完整话题
HTTP 400 {"error":{"message":"Error","details":[{"error":"ERROR_BAD_REQUEST","details":{"title":"Bad Request","detail":"The new pricing opt-out window has ended. New users cannot opt out of the new pricing model.","isRetryable":false,"additionalInfo":{},"buttons":[],"planChoices":[]},"isExpected":true}]}} Thanks, old Cursor Fuck xxx Cursor!! 1 个帖子 - 1 位参与者 阅读完整话题
platform: v2ex node: career title: 翻了 30 份 ATS 系统文档之后,我才发现简历卡掉的真正原因 status: READY 翻了 30 份 ATS 系统文档之后,我才发现简历卡掉的真正原因 最近帮朋友看简历,越看越觉得「为什么投了 50 份连面试电话都没有」这件事,几乎从来不是内容写得不好。 我顺手做了一件可能没什么人会去做的事——把 Greenhouse 、Workday 、iCIMS 、SmartRecruiters 、Lever 、Taleo 这几家在国内外都很常见的 ATS ( Applicant Tracking System )公开开发文档全翻了一遍,然后扒了网上 30 份阅读量比较高的简历模板(来自超级简历、五百丁、掘金、知乎专栏、小红书),用我自己写的一个开源 ATS 解析模拟器跑了一遍。 结论让我挺意外的—— 绝大多数简历不是死在内容,而是死在格式 。HR 那边看到的版本,和你自己 PDF 里看到的,根本不是同一份。 ATS 到底在干什么 先稍微解释一下,省得后面看起来像玄学。 ATS 是几乎所有正规公司在用的简历管理系统。你投递时上传的那份 PDF ,进系统的第一步是被一个叫 resume parser 的模块切碎、抽字段、入库。HR 之后在系统里看到的「候选人卡片」,是 parser 解析出来之后重新拼的。 也就是说: 你 PDF 里看到的不等于 HR 看到的 。 如果你简历里把「教育经历」写成「我的求学之路」,主流 parser 会直接把这段当 unclassified content 丢掉。HR 那边的「教育」字段就是空的。HR 用关键词搜「 985 」「计算机」之类的词筛人时,你压根不在结果里。 这事儿听起来很离谱,但 Greenhouse 自家 Resume Parser API 文档 写得明明白白:parser 依赖的是 standard section headings ,遇到非标准 heading 就 fallback 到机器学习猜测,准确率一下从 95% 掉到 50% 出头。 我跑那 30 份简历的时候, 13 份 因为模块命名问题,至少有一个核心字段(教育、工作、技能其中之一)是 parser 抓不到的。 5 个共性问题 1. 模块名起得太「有创意」 最常见。设计感越强的模板越严重,几乎每份小红书模板都中招。 bad case: 「我的故事」←「自我评价」 「在路上」←「工作经历」 「不打烊的好奇心」←「兴趣爱好」 「关于我」←「个人简介」 ATS 的 parser 大多数是基于 keyword pattern + 规则,匹配的词表非常有限:教育经历 / 教育背景 / 学历 / 工作经历 / 工作经验 / 项目经验 / 项目经历 / 技能特长 / 自我评价。这一类的同义词。 「我的故事」「在路上」这种,parser 一律识别为 custom_section ,content 整段被打入冷宫,HR 在系统里搜不到。你在 PDF 上看着精彩,到了 HR 系统里面就是一段透明文本。 怎么改 :标题写得无聊一点。审美上可能不如那些花活,但这是工程问题不是审美问题。 2. 工作描述只有职位 + 公司,没有量化 第二常见。30 份里 22 份的工作经历是这种结构: 2022.06 - 2023.05 字节跳动 - 后端开发工程师 - 负责短视频推荐系统的开发与维护 - 参与多个核心模块的优化 - 配合产品和算法团队完成迭代 ATS 不光做 keyword extract ,主流系统都接了 LLM 做 summary scoring 。这种文本进 LLM 的输出基本就是「无具体可量化产出」「描述抽象」,scoring 直接低分。HR 那边的候选人卡片上你可能就是个三星,被排在五星候选人后面。 我自己跑测试时,把上面那段改成: - 主导短视频推荐召回链路重构,QPS 从 8w 提升到 23w ,P99 延迟降低 41% - 优化离线特征生产 Pipeline ,将日批耗时从 6.5h 降到 2.1h - 推动从 PHP 迁移到 Go ,相关服务内存占用减半,节约约 40 台服务器 LLM scoring 直接从 2.8 分上到 4.6 分(满分 5 )。 怎么改 :每条 bullet 至少一个数字。不会写就用 STAR:Situation - Task - Action - Result 。结果那一项不能省。没数字编一个「显著」「明显」之类的副词都比没有强,但有真数字最好。 3. 联系方式塞进自定义字段 这个比较细但杀伤力大。 很多模板把头像 + 姓名 + 联系方式做成一整张图(或者塞到 header 里 absolute 定位的 div )。Parser 抽取联系方式时跑的是 field detection ,需要明确的 label:邮箱、电话、Email 、Phone 、E-mail 、Tel 这种。如果你联系方式只是一行 +86 138-xxxx-xxxx | [email protected] 没有 label ,主流 parser 大概有 30% 的概率漏抓。 更糟的是有些设计师爱把联系方式写在侧栏,正文是单栏 + 侧栏的两栏布局。Parser 大多按 reading order 抽文本(即把多栏当成一栏从上到下读),结果你的「邮箱」 label 跑到了「教育经历」的内容里,整段错位。 我跑的 30 份, 6 份 的联系方式 parser 没抓全,HR 系统里要么只有手机没邮箱,要么反过来。 怎么改 :联系方式放在第一页顶部,单栏,一行一个,每行带明确 label (手机:xxx / 邮箱:xxx )。视觉上可能没那么漂亮但保证 parse 得到。 4. 头像没分场景 国内简历加头像几乎是默认的,但你如果投外企( Google 、微软、Meta 、Stripe 、字节海外仓 等),含照片的简历会被 ATS 自动 flag——欧美就业反歧视法规要求 HR 不能基于照片做筛选决定,所以正规外企的 ATS 会直接把含照片的简历 routing 到一个特殊队列,加人工审核之后才放到 HR 桌面,多一道流程意味着竞争对手已经先收到 offer 了。 国企、事业单位、国内大厂民企走相反路子——没头像反而显得不正式,特别是应届生。 怎么改 :投国内 → 标准证件照;投外企 → 一份不带头像的副本。一份模板不可能两边通吃。 5. PDF 里夹图片化的字段 最隐蔽也最致命。我见过一份小红书爆款模板,把整个「技能特长」做成了一张 SVG 图,五个能力雷达图 + 标签云的样式,PDF 里看上去非常炫。 但 parser 对图片基本不处理(除非接了 OCR 全链路,国内大多数 ATS 不接,外资公司接的也不一定走 OCR 通道)。结果 HR 用 ATS 搜 「 Python 」「 React 」时,你那份「技能」全是 SVG ,关键词一个都搜不到,相当于你写了等于没写。 我跑的 30 份里 4 份 有大段图片化内容,里面的字 parser 一个都没抓到。 怎么改 :所有需要被搜索的内容都用纯文本。视觉效果可以靠排版(颜色、间距、icon font )做, 绝对 不要用图片。 一个反直觉的结论 我反复跑这些 case ,最后总结出来一个挺反直觉的规律: 简历的 ATS 友好度和它的视觉精美度大概率成反比 。 那种看上去最普通最朴素的——单栏、宋体或微软雅黑、标题就叫「教育经历」「工作经历」、联系方式整齐排列在顶部、每个工作经历至少 3 条带数字 bullet——这种简历 parser 抓得最准,HR 系统里展示得最完整。 很多设计感强的模板(侧栏、两栏布局、图片化技能、创意标题),看着是对你简历的加分,到了 ATS 那一关其实是负分。 如果你简历投出去几十份没回响,先别急着怀疑自己内容不好——把简历另存成 .txt 看一眼。如果 .txt 里读起来字段错位、内容缺失、标题不通,那 HR 系统里看到的就是这个。 顺便聊一下我做的工具 聊到这里大概也猜到了,我做了个免费在线 ATS 检查器,叫 棱镜简历 。 地址: https://xukz.cn 核心是这两个功能: ATS 体检 ——上传你现有的简历或在站内编辑,30 秒出一份报告:拿了多少分(满分 100 )、哪些字段 parser 抓不到、模块名是否标准、内容饱满度够不够,每条 issue 都告诉你怎么改。 JD 匹配诊断 ——把目标岗位 JD 贴进去,跟你的简历比对,告诉你缺哪些关键词、匹配度多少、技能 gap 在哪。 这两个功能都不收费。完全离线在浏览器里跑( ATS 检查纯前端逻辑,不上传简历内容到服务器),代码我开源了——核心规则在 frontend/src/utils/atsEngine.ts ,有兴趣可以去 GitHub 看具体规则。 PDF 导出 / AI 润色这些是收费的,FREE 用户每月 5 次免费导出,第一次注册送 8 次 AI 额度,新用户首购 ¥1 解锁 7 天 Pro 。 我做这个的初衷是自己求职那阵被 ATS 卡过几次,本来想找现成工具结果没找到合适的中文工具,外文工具( Jobscan / Resume Worded )一年订阅 $200+ 太贵,于是自己写了一个,做着做着就成了完整的简历编辑器。 如果你正在改简历,去跑一下试试,5 分钟知道自己有多少 ATS 问题。即便不用我这个工具,至少把上面 5 个共性问题对照看一遍,应该能让面试电话明显增多。 补充一下,欢迎拍砖,特别是如果你做过 HR 或者亲手用过 Greenhouse / Workday 这些系统的,我描述里有不准的地方麻烦指出来。 [利益相关] 棱镜简历是我自己做的独立项目,挂在 xukz.cn 。
platform: v2ex node: career title: 翻了 30 份 ATS 系统文档之后,我才发现简历卡掉的真正原因 status: READY 翻了 30 份 ATS 系统文档之后,我才发现简历卡掉的真正原因 最近帮朋友看简历,越看越觉得「为什么投了 50 份连面试电话都没有」这件事,几乎从来不是内容写得不好。 我顺手做了一件可能没什么人会去做的事——把 Greenhouse 、Workday 、iCIMS 、SmartRecruiters 、Lever 、Taleo 这几家在国内外都很常见的 ATS ( Applicant Tracking System )公开开发文档全翻了一遍,然后扒了网上 30 份阅读量比较高的简历模板(来自超级简历、五百丁、掘金、知乎专栏、小红书),用我自己写的一个开源 ATS 解析模拟器跑了一遍。 结论让我挺意外的—— 绝大多数简历不是死在内容,而是死在格式 。HR 那边看到的版本,和你自己 PDF 里看到的,根本不是同一份。 ATS 到底在干什么 先稍微解释一下,省得后面看起来像玄学。 ATS 是几乎所有正规公司在用的简历管理系统。你投递时上传的那份 PDF ,进系统的第一步是被一个叫 resume parser 的模块切碎、抽字段、入库。HR 之后在系统里看到的「候选人卡片」,是 parser 解析出来之后重新拼的。 也就是说: 你 PDF 里看到的不等于 HR 看到的 。 如果你简历里把「教育经历」写成「我的求学之路」,主流 parser 会直接把这段当 unclassified content 丢掉。HR 那边的「教育」字段就是空的。HR 用关键词搜「 985 」「计算机」之类的词筛人时,你压根不在结果里。 这事儿听起来很离谱,但 Greenhouse 自家 Resume Parser API 文档 写得明明白白:parser 依赖的是 standard section headings ,遇到非标准 heading 就 fallback 到机器学习猜测,准确率一下从 95% 掉到 50% 出头。 我跑那 30 份简历的时候, 13 份 因为模块命名问题,至少有一个核心字段(教育、工作、技能其中之一)是 parser 抓不到的。 5 个共性问题 1. 模块名起得太「有创意」 最常见。设计感越强的模板越严重,几乎每份小红书模板都中招。 bad case: 「我的故事」←「自我评价」 「在路上」←「工作经历」 「不打烊的好奇心」←「兴趣爱好」 「关于我」←「个人简介」 ATS 的 parser 大多数是基于 keyword pattern + 规则,匹配的词表非常有限:教育经历 / 教育背景 / 学历 / 工作经历 / 工作经验 / 项目经验 / 项目经历 / 技能特长 / 自我评价。这一类的同义词。 「我的故事」「在路上」这种,parser 一律识别为 custom_section ,content 整段被打入冷宫,HR 在系统里搜不到。你在 PDF 上看着精彩,到了 HR 系统里面就是一段透明文本。 怎么改 :标题写得无聊一点。审美上可能不如那些花活,但这是工程问题不是审美问题。 2. 工作描述只有职位 + 公司,没有量化 第二常见。30 份里 22 份的工作经历是这种结构: 2022.06 - 2023.05 字节跳动 - 后端开发工程师 - 负责短视频推荐系统的开发与维护 - 参与多个核心模块的优化 - 配合产品和算法团队完成迭代 ATS 不光做 keyword extract ,主流系统都接了 LLM 做 summary scoring 。这种文本进 LLM 的输出基本就是「无具体可量化产出」「描述抽象」,scoring 直接低分。HR 那边的候选人卡片上你可能就是个三星,被排在五星候选人后面。 我自己跑测试时,把上面那段改成: - 主导短视频推荐召回链路重构,QPS 从 8w 提升到 23w ,P99 延迟降低 41% - 优化离线特征生产 Pipeline ,将日批耗时从 6.5h 降到 2.1h - 推动从 PHP 迁移到 Go ,相关服务内存占用减半,节约约 40 台服务器 LLM scoring 直接从 2.8 分上到 4.6 分(满分 5 )。 怎么改 :每条 bullet 至少一个数字。不会写就用 STAR:Situation - Task - Action - Result 。结果那一项不能省。没数字编一个「显著」「明显」之类的副词都比没有强,但有真数字最好。 3. 联系方式塞进自定义字段 这个比较细但杀伤力大。 很多模板把头像 + 姓名 + 联系方式做成一整张图(或者塞到 header 里 absolute 定位的 div )。Parser 抽取联系方式时跑的是 field detection ,需要明确的 label:邮箱、电话、Email 、Phone 、E-mail 、Tel 这种。如果你联系方式只是一行 +86 138-xxxx-xxxx | [email protected] 没有 label ,主流 parser 大概有 30% 的概率漏抓。 更糟的是有些设计师爱把联系方式写在侧栏,正文是单栏 + 侧栏的两栏布局。Parser 大多按 reading order 抽文本(即把多栏当成一栏从上到下读),结果你的「邮箱」 label 跑到了「教育经历」的内容里,整段错位。 我跑的 30 份, 6 份 的联系方式 parser 没抓全,HR 系统里要么只有手机没邮箱,要么反过来。 怎么改 :联系方式放在第一页顶部,单栏,一行一个,每行带明确 label (手机:xxx / 邮箱:xxx )。视觉上可能没那么漂亮但保证 parse 得到。 4. 头像没分场景 国内简历加头像几乎是默认的,但你如果投外企( Google 、微软、Meta 、Stripe 、字节海外仓 等),含照片的简历会被 ATS 自动 flag——欧美就业反歧视法规要求 HR 不能基于照片做筛选决定,所以正规外企的 ATS 会直接把含照片的简历 routing 到一个特殊队列,加人工审核之后才放到 HR 桌面,多一道流程意味着竞争对手已经先收到 offer 了。 国企、事业单位、国内大厂民企走相反路子——没头像反而显得不正式,特别是应届生。 怎么改 :投国内 → 标准证件照;投外企 → 一份不带头像的副本。一份模板不可能两边通吃。 5. PDF 里夹图片化的字段 最隐蔽也最致命。我见过一份小红书爆款模板,把整个「技能特长」做成了一张 SVG 图,五个能力雷达图 + 标签云的样式,PDF 里看上去非常炫。 但 parser 对图片基本不处理(除非接了 OCR 全链路,国内大多数 ATS 不接,外资公司接的也不一定走 OCR 通道)。结果 HR 用 ATS 搜 「 Python 」「 React 」时,你那份「技能」全是 SVG ,关键词一个都搜不到,相当于你写了等于没写。 我跑的 30 份里 4 份 有大段图片化内容,里面的字 parser 一个都没抓到。 怎么改 :所有需要被搜索的内容都用纯文本。视觉效果可以靠排版(颜色、间距、icon font )做, 绝对 不要用图片。 一个反直觉的结论 我反复跑这些 case ,最后总结出来一个挺反直觉的规律: 简历的 ATS 友好度和它的视觉精美度大概率成反比 。 那种看上去最普通最朴素的——单栏、宋体或微软雅黑、标题就叫「教育经历」「工作经历」、联系方式整齐排列在顶部、每个工作经历至少 3 条带数字 bullet——这种简历 parser 抓得最准,HR 系统里展示得最完整。 很多设计感强的模板(侧栏、两栏布局、图片化技能、创意标题),看着是对你简历的加分,到了 ATS 那一关其实是负分。 如果你简历投出去几十份没回响,先别急着怀疑自己内容不好——把简历另存成 .txt 看一眼。如果 .txt 里读起来字段错位、内容缺失、标题不通,那 HR 系统里看到的就是这个。 顺便聊一下我做的工具 聊到这里大概也猜到了,我做了个免费在线 ATS 检查器,叫 棱镜简历 。 地址: https://xukz.cn 核心是这两个功能: ATS 体检 ——上传你现有的简历或在站内编辑,30 秒出一份报告:拿了多少分(满分 100 )、哪些字段 parser 抓不到、模块名是否标准、内容饱满度够不够,每条 issue 都告诉你怎么改。 JD 匹配诊断 ——把目标岗位 JD 贴进去,跟你的简历比对,告诉你缺哪些关键词、匹配度多少、技能 gap 在哪。 这两个功能都不收费。完全离线在浏览器里跑( ATS 检查纯前端逻辑,不上传简历内容到服务器),代码我开源了——核心规则在 frontend/src/utils/atsEngine.ts ,有兴趣可以去 GitHub 看具体规则。 PDF 导出 / AI 润色这些是收费的,FREE 用户每月 5 次免费导出,第一次注册送 8 次 AI 额度,新用户首购 ¥1 解锁 7 天 Pro 。 我做这个的初衷是自己求职那阵被 ATS 卡过几次,本来想找现成工具结果没找到合适的中文工具,外文工具( Jobscan / Resume Worded )一年订阅 $200+ 太贵,于是自己写了一个,做着做着就成了完整的简历编辑器。 如果你正在改简历,去跑一下试试,5 分钟知道自己有多少 ATS 问题。即便不用我这个工具,至少把上面 5 个共性问题对照看一遍,应该能让面试电话明显增多。 补充一下,欢迎拍砖,特别是如果你做过 HR 或者亲手用过 Greenhouse / Workday 这些系统的,我描述里有不准的地方麻烦指出来。 [利益相关] 棱镜简历是我自己做的独立项目,挂在 xukz.cn 。
platform: v2ex node: career title: 翻了 30 份 ATS 系统文档之后,我才发现简历卡掉的真正原因 status: READY 翻了 30 份 ATS 系统文档之后,我才发现简历卡掉的真正原因 最近帮朋友看简历,越看越觉得「为什么投了 50 份连面试电话都没有」这件事,几乎从来不是内容写得不好。 我顺手做了一件可能没什么人会去做的事——把 Greenhouse 、Workday 、iCIMS 、SmartRecruiters 、Lever 、Taleo 这几家在国内外都很常见的 ATS ( Applicant Tracking System )公开开发文档全翻了一遍,然后扒了网上 30 份阅读量比较高的简历模板(来自超级简历、五百丁、掘金、知乎专栏、小红书),用我自己写的一个开源 ATS 解析模拟器跑了一遍。 结论让我挺意外的—— 绝大多数简历不是死在内容,而是死在格式 。HR 那边看到的版本,和你自己 PDF 里看到的,根本不是同一份。 ATS 到底在干什么 先稍微解释一下,省得后面看起来像玄学。 ATS 是几乎所有正规公司在用的简历管理系统。你投递时上传的那份 PDF ,进系统的第一步是被一个叫 resume parser 的模块切碎、抽字段、入库。HR 之后在系统里看到的「候选人卡片」,是 parser 解析出来之后重新拼的。 也就是说: 你 PDF 里看到的不等于 HR 看到的 。 如果你简历里把「教育经历」写成「我的求学之路」,主流 parser 会直接把这段当 unclassified content 丢掉。HR 那边的「教育」字段就是空的。HR 用关键词搜「 985 」「计算机」之类的词筛人时,你压根不在结果里。 这事儿听起来很离谱,但 Greenhouse 自家 Resume Parser API 文档 写得明明白白:parser 依赖的是 standard section headings ,遇到非标准 heading 就 fallback 到机器学习猜测,准确率一下从 95% 掉到 50% 出头。 我跑那 30 份简历的时候, 13 份 因为模块命名问题,至少有一个核心字段(教育、工作、技能其中之一)是 parser 抓不到的。 5 个共性问题 1. 模块名起得太「有创意」 最常见。设计感越强的模板越严重,几乎每份小红书模板都中招。 bad case: 「我的故事」←「自我评价」 「在路上」←「工作经历」 「不打烊的好奇心」←「兴趣爱好」 「关于我」←「个人简介」 ATS 的 parser 大多数是基于 keyword pattern + 规则,匹配的词表非常有限:教育经历 / 教育背景 / 学历 / 工作经历 / 工作经验 / 项目经验 / 项目经历 / 技能特长 / 自我评价。这一类的同义词。 「我的故事」「在路上」这种,parser 一律识别为 custom_section ,content 整段被打入冷宫,HR 在系统里搜不到。你在 PDF 上看着精彩,到了 HR 系统里面就是一段透明文本。 怎么改 :标题写得无聊一点。审美上可能不如那些花活,但这是工程问题不是审美问题。 2. 工作描述只有职位 + 公司,没有量化 第二常见。30 份里 22 份的工作经历是这种结构: 2022.06 - 2023.05 字节跳动 - 后端开发工程师 - 负责短视频推荐系统的开发与维护 - 参与多个核心模块的优化 - 配合产品和算法团队完成迭代 ATS 不光做 keyword extract ,主流系统都接了 LLM 做 summary scoring 。这种文本进 LLM 的输出基本就是「无具体可量化产出」「描述抽象」,scoring 直接低分。HR 那边的候选人卡片上你可能就是个三星,被排在五星候选人后面。 我自己跑测试时,把上面那段改成: - 主导短视频推荐召回链路重构,QPS 从 8w 提升到 23w ,P99 延迟降低 41% - 优化离线特征生产 Pipeline ,将日批耗时从 6.5h 降到 2.1h - 推动从 PHP 迁移到 Go ,相关服务内存占用减半,节约约 40 台服务器 LLM scoring 直接从 2.8 分上到 4.6 分(满分 5 )。 怎么改 :每条 bullet 至少一个数字。不会写就用 STAR:Situation - Task - Action - Result 。结果那一项不能省。没数字编一个「显著」「明显」之类的副词都比没有强,但有真数字最好。 3. 联系方式塞进自定义字段 这个比较细但杀伤力大。 很多模板把头像 + 姓名 + 联系方式做成一整张图(或者塞到 header 里 absolute 定位的 div )。Parser 抽取联系方式时跑的是 field detection ,需要明确的 label:邮箱、电话、Email 、Phone 、E-mail 、Tel 这种。如果你联系方式只是一行 +86 138-xxxx-xxxx | [email protected] 没有 label ,主流 parser 大概有 30% 的概率漏抓。 更糟的是有些设计师爱把联系方式写在侧栏,正文是单栏 + 侧栏的两栏布局。Parser 大多按 reading order 抽文本(即把多栏当成一栏从上到下读),结果你的「邮箱」 label 跑到了「教育经历」的内容里,整段错位。 我跑的 30 份, 6 份 的联系方式 parser 没抓全,HR 系统里要么只有手机没邮箱,要么反过来。 怎么改 :联系方式放在第一页顶部,单栏,一行一个,每行带明确 label (手机:xxx / 邮箱:xxx )。视觉上可能没那么漂亮但保证 parse 得到。 4. 头像没分场景 国内简历加头像几乎是默认的,但你如果投外企( Google 、微软、Meta 、Stripe 、字节海外仓 等),含照片的简历会被 ATS 自动 flag——欧美就业反歧视法规要求 HR 不能基于照片做筛选决定,所以正规外企的 ATS 会直接把含照片的简历 routing 到一个特殊队列,加人工审核之后才放到 HR 桌面,多一道流程意味着竞争对手已经先收到 offer 了。 国企、事业单位、国内大厂民企走相反路子——没头像反而显得不正式,特别是应届生。 怎么改 :投国内 → 标准证件照;投外企 → 一份不带头像的副本。一份模板不可能两边通吃。 5. PDF 里夹图片化的字段 最隐蔽也最致命。我见过一份小红书爆款模板,把整个「技能特长」做成了一张 SVG 图,五个能力雷达图 + 标签云的样式,PDF 里看上去非常炫。 但 parser 对图片基本不处理(除非接了 OCR 全链路,国内大多数 ATS 不接,外资公司接的也不一定走 OCR 通道)。结果 HR 用 ATS 搜 「 Python 」「 React 」时,你那份「技能」全是 SVG ,关键词一个都搜不到,相当于你写了等于没写。 我跑的 30 份里 4 份 有大段图片化内容,里面的字 parser 一个都没抓到。 怎么改 :所有需要被搜索的内容都用纯文本。视觉效果可以靠排版(颜色、间距、icon font )做, 绝对 不要用图片。 一个反直觉的结论 我反复跑这些 case ,最后总结出来一个挺反直觉的规律: 简历的 ATS 友好度和它的视觉精美度大概率成反比 。 那种看上去最普通最朴素的——单栏、宋体或微软雅黑、标题就叫「教育经历」「工作经历」、联系方式整齐排列在顶部、每个工作经历至少 3 条带数字 bullet——这种简历 parser 抓得最准,HR 系统里展示得最完整。 很多设计感强的模板(侧栏、两栏布局、图片化技能、创意标题),看着是对你简历的加分,到了 ATS 那一关其实是负分。 如果你简历投出去几十份没回响,先别急着怀疑自己内容不好——把简历另存成 .txt 看一眼。如果 .txt 里读起来字段错位、内容缺失、标题不通,那 HR 系统里看到的就是这个。 顺便聊一下我做的工具 聊到这里大概也猜到了,我做了个免费在线 ATS 检查器,叫 棱镜简历 。 地址: https://xukz.cn 核心是这两个功能: ATS 体检 ——上传你现有的简历或在站内编辑,30 秒出一份报告:拿了多少分(满分 100 )、哪些字段 parser 抓不到、模块名是否标准、内容饱满度够不够,每条 issue 都告诉你怎么改。 JD 匹配诊断 ——把目标岗位 JD 贴进去,跟你的简历比对,告诉你缺哪些关键词、匹配度多少、技能 gap 在哪。 这两个功能都不收费。完全离线在浏览器里跑( ATS 检查纯前端逻辑,不上传简历内容到服务器),代码我开源了——核心规则在 frontend/src/utils/atsEngine.ts ,有兴趣可以去 GitHub 看具体规则。 PDF 导出 / AI 润色这些是收费的,FREE 用户每月 5 次免费导出,第一次注册送 8 次 AI 额度,新用户首购 ¥1 解锁 7 天 Pro 。 我做这个的初衷是自己求职那阵被 ATS 卡过几次,本来想找现成工具结果没找到合适的中文工具,外文工具( Jobscan / Resume Worded )一年订阅 $200+ 太贵,于是自己写了一个,做着做着就成了完整的简历编辑器。 如果你正在改简历,去跑一下试试,5 分钟知道自己有多少 ATS 问题。即便不用我这个工具,至少把上面 5 个共性问题对照看一遍,应该能让面试电话明显增多。 补充一下,欢迎拍砖,特别是如果你做过 HR 或者亲手用过 Greenhouse / Workday 这些系统的,我描述里有不准的地方麻烦指出来。 [利益相关] 棱镜简历是我自己做的独立项目,挂在 xukz.cn 。
platform: v2ex node: career title: 翻了 30 份 ATS 系统文档之后,我才发现简历卡掉的真正原因 status: READY 翻了 30 份 ATS 系统文档之后,我才发现简历卡掉的真正原因 最近帮朋友看简历,越看越觉得「为什么投了 50 份连面试电话都没有」这件事,几乎从来不是内容写得不好。 我顺手做了一件可能没什么人会去做的事——把 Greenhouse 、Workday 、iCIMS 、SmartRecruiters 、Lever 、Taleo 这几家在国内外都很常见的 ATS ( Applicant Tracking System )公开开发文档全翻了一遍,然后扒了网上 30 份阅读量比较高的简历模板(来自超级简历、五百丁、掘金、知乎专栏、小红书),用我自己写的一个开源 ATS 解析模拟器跑了一遍。 结论让我挺意外的—— 绝大多数简历不是死在内容,而是死在格式 。HR 那边看到的版本,和你自己 PDF 里看到的,根本不是同一份。 ATS 到底在干什么 先稍微解释一下,省得后面看起来像玄学。 ATS 是几乎所有正规公司在用的简历管理系统。你投递时上传的那份 PDF ,进系统的第一步是被一个叫 resume parser 的模块切碎、抽字段、入库。HR 之后在系统里看到的「候选人卡片」,是 parser 解析出来之后重新拼的。 也就是说: 你 PDF 里看到的不等于 HR 看到的 。 如果你简历里把「教育经历」写成「我的求学之路」,主流 parser 会直接把这段当 unclassified content 丢掉。HR 那边的「教育」字段就是空的。HR 用关键词搜「 985 」「计算机」之类的词筛人时,你压根不在结果里。 这事儿听起来很离谱,但 Greenhouse 自家 Resume Parser API 文档 写得明明白白:parser 依赖的是 standard section headings ,遇到非标准 heading 就 fallback 到机器学习猜测,准确率一下从 95% 掉到 50% 出头。 我跑那 30 份简历的时候, 13 份 因为模块命名问题,至少有一个核心字段(教育、工作、技能其中之一)是 parser 抓不到的。 5 个共性问题 1. 模块名起得太「有创意」 最常见。设计感越强的模板越严重,几乎每份小红书模板都中招。 bad case: 「我的故事」←「自我评价」 「在路上」←「工作经历」 「不打烊的好奇心」←「兴趣爱好」 「关于我」←「个人简介」 ATS 的 parser 大多数是基于 keyword pattern + 规则,匹配的词表非常有限:教育经历 / 教育背景 / 学历 / 工作经历 / 工作经验 / 项目经验 / 项目经历 / 技能特长 / 自我评价。这一类的同义词。 「我的故事」「在路上」这种,parser 一律识别为 custom_section ,content 整段被打入冷宫,HR 在系统里搜不到。你在 PDF 上看着精彩,到了 HR 系统里面就是一段透明文本。 怎么改 :标题写得无聊一点。审美上可能不如那些花活,但这是工程问题不是审美问题。 2. 工作描述只有职位 + 公司,没有量化 第二常见。30 份里 22 份的工作经历是这种结构: 2022.06 - 2023.05 字节跳动 - 后端开发工程师 - 负责短视频推荐系统的开发与维护 - 参与多个核心模块的优化 - 配合产品和算法团队完成迭代 ATS 不光做 keyword extract ,主流系统都接了 LLM 做 summary scoring 。这种文本进 LLM 的输出基本就是「无具体可量化产出」「描述抽象」,scoring 直接低分。HR 那边的候选人卡片上你可能就是个三星,被排在五星候选人后面。 我自己跑测试时,把上面那段改成: - 主导短视频推荐召回链路重构,QPS 从 8w 提升到 23w ,P99 延迟降低 41% - 优化离线特征生产 Pipeline ,将日批耗时从 6.5h 降到 2.1h - 推动从 PHP 迁移到 Go ,相关服务内存占用减半,节约约 40 台服务器 LLM scoring 直接从 2.8 分上到 4.6 分(满分 5 )。 怎么改 :每条 bullet 至少一个数字。不会写就用 STAR:Situation - Task - Action - Result 。结果那一项不能省。没数字编一个「显著」「明显」之类的副词都比没有强,但有真数字最好。 3. 联系方式塞进自定义字段 这个比较细但杀伤力大。 很多模板把头像 + 姓名 + 联系方式做成一整张图(或者塞到 header 里 absolute 定位的 div )。Parser 抽取联系方式时跑的是 field detection ,需要明确的 label:邮箱、电话、Email 、Phone 、E-mail 、Tel 这种。如果你联系方式只是一行 +86 138-xxxx-xxxx | [email protected] 没有 label ,主流 parser 大概有 30% 的概率漏抓。 更糟的是有些设计师爱把联系方式写在侧栏,正文是单栏 + 侧栏的两栏布局。Parser 大多按 reading order 抽文本(即把多栏当成一栏从上到下读),结果你的「邮箱」 label 跑到了「教育经历」的内容里,整段错位。 我跑的 30 份, 6 份 的联系方式 parser 没抓全,HR 系统里要么只有手机没邮箱,要么反过来。 怎么改 :联系方式放在第一页顶部,单栏,一行一个,每行带明确 label (手机:xxx / 邮箱:xxx )。视觉上可能没那么漂亮但保证 parse 得到。 4. 头像没分场景 国内简历加头像几乎是默认的,但你如果投外企( Google 、微软、Meta 、Stripe 、字节海外仓 等),含照片的简历会被 ATS 自动 flag——欧美就业反歧视法规要求 HR 不能基于照片做筛选决定,所以正规外企的 ATS 会直接把含照片的简历 routing 到一个特殊队列,加人工审核之后才放到 HR 桌面,多一道流程意味着竞争对手已经先收到 offer 了。 国企、事业单位、国内大厂民企走相反路子——没头像反而显得不正式,特别是应届生。 怎么改 :投国内 → 标准证件照;投外企 → 一份不带头像的副本。一份模板不可能两边通吃。 5. PDF 里夹图片化的字段 最隐蔽也最致命。我见过一份小红书爆款模板,把整个「技能特长」做成了一张 SVG 图,五个能力雷达图 + 标签云的样式,PDF 里看上去非常炫。 但 parser 对图片基本不处理(除非接了 OCR 全链路,国内大多数 ATS 不接,外资公司接的也不一定走 OCR 通道)。结果 HR 用 ATS 搜 「 Python 」「 React 」时,你那份「技能」全是 SVG ,关键词一个都搜不到,相当于你写了等于没写。 我跑的 30 份里 4 份 有大段图片化内容,里面的字 parser 一个都没抓到。 怎么改 :所有需要被搜索的内容都用纯文本。视觉效果可以靠排版(颜色、间距、icon font )做, 绝对 不要用图片。 一个反直觉的结论 我反复跑这些 case ,最后总结出来一个挺反直觉的规律: 简历的 ATS 友好度和它的视觉精美度大概率成反比 。 那种看上去最普通最朴素的——单栏、宋体或微软雅黑、标题就叫「教育经历」「工作经历」、联系方式整齐排列在顶部、每个工作经历至少 3 条带数字 bullet——这种简历 parser 抓得最准,HR 系统里展示得最完整。 很多设计感强的模板(侧栏、两栏布局、图片化技能、创意标题),看着是对你简历的加分,到了 ATS 那一关其实是负分。 如果你简历投出去几十份没回响,先别急着怀疑自己内容不好——把简历另存成 .txt 看一眼。如果 .txt 里读起来字段错位、内容缺失、标题不通,那 HR 系统里看到的就是这个。 顺便聊一下我做的工具 聊到这里大概也猜到了,我做了个免费在线 ATS 检查器,叫 棱镜简历 。 地址: https://xukz.cn 核心是这两个功能: ATS 体检 ——上传你现有的简历或在站内编辑,30 秒出一份报告:拿了多少分(满分 100 )、哪些字段 parser 抓不到、模块名是否标准、内容饱满度够不够,每条 issue 都告诉你怎么改。 JD 匹配诊断 ——把目标岗位 JD 贴进去,跟你的简历比对,告诉你缺哪些关键词、匹配度多少、技能 gap 在哪。 这两个功能都不收费。完全离线在浏览器里跑( ATS 检查纯前端逻辑,不上传简历内容到服务器),代码我开源了——核心规则在 frontend/src/utils/atsEngine.ts ,有兴趣可以去 GitHub 看具体规则。 PDF 导出 / AI 润色这些是收费的,FREE 用户每月 5 次免费导出,第一次注册送 8 次 AI 额度,新用户首购 ¥1 解锁 7 天 Pro 。 我做这个的初衷是自己求职那阵被 ATS 卡过几次,本来想找现成工具结果没找到合适的中文工具,外文工具( Jobscan / Resume Worded )一年订阅 $200+ 太贵,于是自己写了一个,做着做着就成了完整的简历编辑器。 如果你正在改简历,去跑一下试试,5 分钟知道自己有多少 ATS 问题。即便不用我这个工具,至少把上面 5 个共性问题对照看一遍,应该能让面试电话明显增多。 补充一下,欢迎拍砖,特别是如果你做过 HR 或者亲手用过 Greenhouse / Workday 这些系统的,我描述里有不准的地方麻烦指出来。 [利益相关] 棱镜简历是我自己做的独立项目,挂在 xukz.cn 。
platform: v2ex node: career title: 翻了 30 份 ATS 系统文档之后,我才发现简历卡掉的真正原因 status: READY 翻了 30 份 ATS 系统文档之后,我才发现简历卡掉的真正原因 最近帮朋友看简历,越看越觉得「为什么投了 50 份连面试电话都没有」这件事,几乎从来不是内容写得不好。 我顺手做了一件可能没什么人会去做的事——把 Greenhouse 、Workday 、iCIMS 、SmartRecruiters 、Lever 、Taleo 这几家在国内外都很常见的 ATS ( Applicant Tracking System )公开开发文档全翻了一遍,然后扒了网上 30 份阅读量比较高的简历模板(来自超级简历、五百丁、掘金、知乎专栏、小红书),用我自己写的一个开源 ATS 解析模拟器跑了一遍。 结论让我挺意外的—— 绝大多数简历不是死在内容,而是死在格式 。HR 那边看到的版本,和你自己 PDF 里看到的,根本不是同一份。 ATS 到底在干什么 先稍微解释一下,省得后面看起来像玄学。 ATS 是几乎所有正规公司在用的简历管理系统。你投递时上传的那份 PDF ,进系统的第一步是被一个叫 resume parser 的模块切碎、抽字段、入库。HR 之后在系统里看到的「候选人卡片」,是 parser 解析出来之后重新拼的。 也就是说: 你 PDF 里看到的不等于 HR 看到的 。 如果你简历里把「教育经历」写成「我的求学之路」,主流 parser 会直接把这段当 unclassified content 丢掉。HR 那边的「教育」字段就是空的。HR 用关键词搜「 985 」「计算机」之类的词筛人时,你压根不在结果里。 这事儿听起来很离谱,但 Greenhouse 自家 Resume Parser API 文档 写得明明白白:parser 依赖的是 standard section headings ,遇到非标准 heading 就 fallback 到机器学习猜测,准确率一下从 95% 掉到 50% 出头。 我跑那 30 份简历的时候, 13 份 因为模块命名问题,至少有一个核心字段(教育、工作、技能其中之一)是 parser 抓不到的。 5 个共性问题 1. 模块名起得太「有创意」 最常见。设计感越强的模板越严重,几乎每份小红书模板都中招。 bad case: 「我的故事」←「自我评价」 「在路上」←「工作经历」 「不打烊的好奇心」←「兴趣爱好」 「关于我」←「个人简介」 ATS 的 parser 大多数是基于 keyword pattern + 规则,匹配的词表非常有限:教育经历 / 教育背景 / 学历 / 工作经历 / 工作经验 / 项目经验 / 项目经历 / 技能特长 / 自我评价。这一类的同义词。 「我的故事」「在路上」这种,parser 一律识别为 custom_section ,content 整段被打入冷宫,HR 在系统里搜不到。你在 PDF 上看着精彩,到了 HR 系统里面就是一段透明文本。 怎么改 :标题写得无聊一点。审美上可能不如那些花活,但这是工程问题不是审美问题。 2. 工作描述只有职位 + 公司,没有量化 第二常见。30 份里 22 份的工作经历是这种结构: 2022.06 - 2023.05 字节跳动 - 后端开发工程师 - 负责短视频推荐系统的开发与维护 - 参与多个核心模块的优化 - 配合产品和算法团队完成迭代 ATS 不光做 keyword extract ,主流系统都接了 LLM 做 summary scoring 。这种文本进 LLM 的输出基本就是「无具体可量化产出」「描述抽象」,scoring 直接低分。HR 那边的候选人卡片上你可能就是个三星,被排在五星候选人后面。 我自己跑测试时,把上面那段改成: - 主导短视频推荐召回链路重构,QPS 从 8w 提升到 23w ,P99 延迟降低 41% - 优化离线特征生产 Pipeline ,将日批耗时从 6.5h 降到 2.1h - 推动从 PHP 迁移到 Go ,相关服务内存占用减半,节约约 40 台服务器 LLM scoring 直接从 2.8 分上到 4.6 分(满分 5 )。 怎么改 :每条 bullet 至少一个数字。不会写就用 STAR:Situation - Task - Action - Result 。结果那一项不能省。没数字编一个「显著」「明显」之类的副词都比没有强,但有真数字最好。 3. 联系方式塞进自定义字段 这个比较细但杀伤力大。 很多模板把头像 + 姓名 + 联系方式做成一整张图(或者塞到 header 里 absolute 定位的 div )。Parser 抽取联系方式时跑的是 field detection ,需要明确的 label:邮箱、电话、Email 、Phone 、E-mail 、Tel 这种。如果你联系方式只是一行 +86 138-xxxx-xxxx | [email protected] 没有 label ,主流 parser 大概有 30% 的概率漏抓。 更糟的是有些设计师爱把联系方式写在侧栏,正文是单栏 + 侧栏的两栏布局。Parser 大多按 reading order 抽文本(即把多栏当成一栏从上到下读),结果你的「邮箱」 label 跑到了「教育经历」的内容里,整段错位。 我跑的 30 份, 6 份 的联系方式 parser 没抓全,HR 系统里要么只有手机没邮箱,要么反过来。 怎么改 :联系方式放在第一页顶部,单栏,一行一个,每行带明确 label (手机:xxx / 邮箱:xxx )。视觉上可能没那么漂亮但保证 parse 得到。 4. 头像没分场景 国内简历加头像几乎是默认的,但你如果投外企( Google 、微软、Meta 、Stripe 、字节海外仓 等),含照片的简历会被 ATS 自动 flag——欧美就业反歧视法规要求 HR 不能基于照片做筛选决定,所以正规外企的 ATS 会直接把含照片的简历 routing 到一个特殊队列,加人工审核之后才放到 HR 桌面,多一道流程意味着竞争对手已经先收到 offer 了。 国企、事业单位、国内大厂民企走相反路子——没头像反而显得不正式,特别是应届生。 怎么改 :投国内 → 标准证件照;投外企 → 一份不带头像的副本。一份模板不可能两边通吃。 5. PDF 里夹图片化的字段 最隐蔽也最致命。我见过一份小红书爆款模板,把整个「技能特长」做成了一张 SVG 图,五个能力雷达图 + 标签云的样式,PDF 里看上去非常炫。 但 parser 对图片基本不处理(除非接了 OCR 全链路,国内大多数 ATS 不接,外资公司接的也不一定走 OCR 通道)。结果 HR 用 ATS 搜 「 Python 」「 React 」时,你那份「技能」全是 SVG ,关键词一个都搜不到,相当于你写了等于没写。 我跑的 30 份里 4 份 有大段图片化内容,里面的字 parser 一个都没抓到。 怎么改 :所有需要被搜索的内容都用纯文本。视觉效果可以靠排版(颜色、间距、icon font )做, 绝对 不要用图片。 一个反直觉的结论 我反复跑这些 case ,最后总结出来一个挺反直觉的规律: 简历的 ATS 友好度和它的视觉精美度大概率成反比 。 那种看上去最普通最朴素的——单栏、宋体或微软雅黑、标题就叫「教育经历」「工作经历」、联系方式整齐排列在顶部、每个工作经历至少 3 条带数字 bullet——这种简历 parser 抓得最准,HR 系统里展示得最完整。 很多设计感强的模板(侧栏、两栏布局、图片化技能、创意标题),看着是对你简历的加分,到了 ATS 那一关其实是负分。 如果你简历投出去几十份没回响,先别急着怀疑自己内容不好——把简历另存成 .txt 看一眼。如果 .txt 里读起来字段错位、内容缺失、标题不通,那 HR 系统里看到的就是这个。 顺便聊一下我做的工具 聊到这里大概也猜到了,我做了个免费在线 ATS 检查器,叫 棱镜简历 。 地址: https://xukz.cn 核心是这两个功能: ATS 体检 ——上传你现有的简历或在站内编辑,30 秒出一份报告:拿了多少分(满分 100 )、哪些字段 parser 抓不到、模块名是否标准、内容饱满度够不够,每条 issue 都告诉你怎么改。 JD 匹配诊断 ——把目标岗位 JD 贴进去,跟你的简历比对,告诉你缺哪些关键词、匹配度多少、技能 gap 在哪。 这两个功能都不收费。完全离线在浏览器里跑( ATS 检查纯前端逻辑,不上传简历内容到服务器),代码我开源了——核心规则在 frontend/src/utils/atsEngine.ts ,有兴趣可以去 GitHub 看具体规则。 PDF 导出 / AI 润色这些是收费的,FREE 用户每月 5 次免费导出,第一次注册送 8 次 AI 额度,新用户首购 ¥1 解锁 7 天 Pro 。 我做这个的初衷是自己求职那阵被 ATS 卡过几次,本来想找现成工具结果没找到合适的中文工具,外文工具( Jobscan / Resume Worded )一年订阅 $200+ 太贵,于是自己写了一个,做着做着就成了完整的简历编辑器。 如果你正在改简历,去跑一下试试,5 分钟知道自己有多少 ATS 问题。即便不用我这个工具,至少把上面 5 个共性问题对照看一遍,应该能让面试电话明显增多。 补充一下,欢迎拍砖,特别是如果你做过 HR 或者亲手用过 Greenhouse / Workday 这些系统的,我描述里有不准的地方麻烦指出来。 [利益相关] 棱镜简历是我自己做的独立项目,挂在 xukz.cn 。
浙江联通客户日 收个验证码就可以抽奖,平均1块话费吧 {{pageTitle}} 非浙江也能抽奖 但是好像不给话费??不太确定 1 个帖子 - 1 位参与者 阅读完整话题
同一个帖子 https://linux.do/n/topic/1668279 https://linux.do/t/topic/1668279 2 个帖子 - 2 位参与者 阅读完整话题
Jiří Procházka showed Carlos Ulberg too much mercy, and Josh Hokit reached beyond gimmicks at UFC 327.
City Kickboxing has a new champion following Carlos Ulberg's title win over Jiří Procházka at UFC 327.
Moments after suffering an apparent injury to his knee, Carlos Ulberg rocked Jiří Procházka with a left hook en route to a knockout victory to become the new light heavyweight champion Saturday night at UFC 327.