build_preview.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. """build_preview.py —— 用已生成素材打一个单文件 HTML 可玩预览。
  2. 把 out/<game>/ 里的角色 + UI 美术按比例内嵌成
  3. base64,生成一个 index.html:手机竖屏布局、霓虹框包住果冻网格、可点 SPIN
  4. 跑"同款聚 5 只就消除→连锁倍数"的玩法。纯前端、双击即玩,无需任何后端。
  5. 用法: python build_preview.py [game] [输出html路径]
  6. """
  7. import base64
  8. import io
  9. import json
  10. import os
  11. import sys
  12. from PIL import Image
  13. HERE = os.path.dirname(os.path.abspath(__file__))
  14. GAME = sys.argv[1] if len(sys.argv) > 1 else "jelly-candy-slot"
  15. OUT = sys.argv[2] if len(sys.argv) > 2 else os.path.join(HERE, "out", GAME, f"JellyPop预览.html")
  16. BASE = os.path.join(HERE, "out", GAME)
  17. def b64(img, maxside, fmt="PNG"):
  18. im = img.convert("RGBA")
  19. w, h = im.size
  20. s = min(1.0, maxside / max(w, h))
  21. if s < 1.0:
  22. im = im.resize((max(1, int(w * s)), max(1, int(h * s))), Image.LANCZOS)
  23. buf = io.BytesIO()
  24. im.save(buf, fmt)
  25. return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
  26. def load(p):
  27. return Image.open(p).convert("RGBA")
  28. lib = json.load(open(os.path.join(BASE, "library.json"), encoding="utf-8"))
  29. char_ids = [c["id"] for c in lib.get("characters", [])]
  30. # 内嵌资源(按用途控制尺寸,平衡清晰度与体积)
  31. A = {}
  32. for cid in char_ids:
  33. A[cid] = b64(load(os.path.join(BASE, "characters", f"{cid}.png")), 190)
  34. ui = os.path.join(BASE, "ui_art")
  35. A["bg"] = b64(load(os.path.join(ui, "bg_main.png")), 820)
  36. A["logo"] = b64(load(os.path.join(ui, "logo.png")), 560)
  37. A["frame"] = b64(load(os.path.join(ui, "reel_frame.png")), 620)
  38. A["spin"] = b64(load(os.path.join(ui, "btn_spin.png")), 220)
  39. A["round"] = b64(load(os.path.join(ui, "btn_round.png")), 150)
  40. SYMS = json.dumps(char_ids, ensure_ascii=False)
  41. ASSETS = json.dumps(A, ensure_ascii=False)
  42. HTML = r"""<!DOCTYPE html>
  43. <html lang="zh">
  44. <head>
  45. <meta charset="utf-8">
  46. <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
  47. <title>Jelly Pop · 果冻消消乐</title>
  48. <style>
  49. *{box-sizing:border-box;-webkit-tap-highlight-color:transparent;margin:0;padding:0}
  50. html,body{height:100%;background:#0e0a1c;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
  51. display:flex;align-items:center;justify-content:center;overflow:hidden}
  52. #phone{position:relative;height:min(100vh,calc(100vw*1.7778));aspect-ratio:9/16;
  53. border-radius:26px;overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,.6);user-select:none}
  54. #bg{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
  55. #shade{position:absolute;inset:0;background:radial-gradient(120% 80% at 50% 0%,rgba(0,0,0,0) 40%,rgba(20,8,40,.45) 100%)}
  56. .layer{position:absolute;left:0;right:0}
  57. #logo{top:1.5%;height:13%;margin:auto;display:block}
  58. #logo img{height:100%;display:block;margin:auto;filter:drop-shadow(0 4px 8px rgba(0,0,0,.35))}
  59. #frameWrap{position:absolute;top:14.5%;height:55%;left:50%;transform:translateX(-50%);aspect-ratio:.737}
  60. #frameImg{position:absolute;inset:0;width:100%;height:100%}
  61. /* 霓虹框内孔 ≈ 左右各 15%、上下各 3% */
  62. #panel{position:absolute;left:15%;right:15%;top:3%;bottom:3%;border-radius:7%/4%;
  63. background:linear-gradient(180deg,rgba(255,255,255,.30),rgba(220,235,255,.16));
  64. backdrop-filter:blur(2px)}
  65. #grid{position:absolute;left:15%;right:15%;top:3%;bottom:3%;display:grid;
  66. grid-template-columns:repeat(5,1fr);grid-template-rows:repeat(7,1fr);gap:1.5%;padding:2%}
  67. .cell{position:relative;display:flex;align-items:center;justify-content:center}
  68. .cell img{width:100%;height:100%;object-fit:contain;
  69. filter:drop-shadow(0 2px 3px rgba(0,0,0,.28));transition:transform .12s ease}
  70. .cell.pop img{animation:pop .42s ease both}
  71. .cell.drop img{animation:drop .34s cubic-bezier(.2,1.3,.5,1) both}
  72. @keyframes pop{0%{transform:scale(1)}35%{transform:scale(1.28) rotate(6deg)}
  73. 70%{transform:scale(.2);opacity:.2}100%{transform:scale(.2);opacity:0}}
  74. @keyframes drop{0%{transform:translateY(-60%) scale(.7);opacity:0}
  75. 100%{transform:translateY(0) scale(1);opacity:1}}
  76. #mult{position:absolute;top:33%;left:0;right:0;text-align:center;font-weight:900;
  77. font-size:7vh;color:#ffd23f;text-shadow:0 3px 0 #c0392b,0 0 18px rgba(255,170,40,.7);
  78. opacity:0;pointer-events:none;transform:scale(.5)}
  79. #mult.show{animation:flash .9s ease both}
  80. @keyframes flash{0%{opacity:0;transform:scale(.4)}25%{opacity:1;transform:scale(1.15)}
  81. 70%{opacity:1;transform:scale(1)}100%{opacity:0;transform:scale(1)}}
  82. #hud{bottom:18.5%;height:8%;display:flex;gap:3%;padding:0 5%;margin:auto}
  83. .pill{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;
  84. background:linear-gradient(180deg,rgba(86,62,150,.92),rgba(54,38,102,.95));
  85. border:2px solid rgba(160,140,235,.9);border-radius:999px;color:#fff;
  86. box-shadow:inset 0 2px 6px rgba(255,255,255,.18),0 4px 10px rgba(0,0,0,.3)}
  87. .pill .k{font-size:1.5vh;letter-spacing:.1em;color:#cbd6ff;opacity:.95}
  88. .pill .v{font-size:2.5vh;font-weight:800;line-height:1.1}
  89. #ctrl{bottom:3%;height:14%;display:flex;align-items:center;justify-content:space-between;
  90. padding:0 6%;margin:auto}
  91. .cbtn{display:flex;flex-direction:column;align-items:center;gap:4px;cursor:pointer}
  92. .rb{position:relative;display:flex;align-items:center;justify-content:center;color:#fff;
  93. font-weight:800;background:none;border:none}
  94. .rb img{position:absolute;inset:0;width:100%;height:100%}
  95. .rb span{position:relative;z-index:1;text-shadow:0 1px 2px rgba(0,0,0,.4)}
  96. .small{width:8.5vh;height:8.5vh;font-size:3.2vh}
  97. #spin{width:13.5vh;height:13.5vh;cursor:pointer;transition:transform .1s}
  98. #spin:active{transform:scale(.92)}
  99. #spin.spinning{animation:spin 1s linear infinite}
  100. @keyframes spin{to{transform:rotate(360deg)}}
  101. .lab{font-size:1.5vh;color:#fff;letter-spacing:.12em;opacity:.92;font-weight:700}
  102. .on{color:#ffd23f;text-shadow:0 0 8px rgba(255,200,60,.8)}
  103. #coins{position:absolute;inset:0;pointer-events:none;overflow:hidden}
  104. .coin{position:absolute;width:5%;will-change:transform}
  105. .hint{position:absolute;bottom:.4%;left:0;right:0;text-align:center;color:#fff;
  106. font-size:1.4vh;opacity:.5}
  107. </style>
  108. </head>
  109. <body>
  110. <div id="phone">
  111. <img id="bg">
  112. <div id="shade"></div>
  113. <div id="logo" class="layer"><img></div>
  114. <div id="frameWrap">
  115. <div id="panel"></div>
  116. <div id="grid"></div>
  117. <img id="frameImg">
  118. </div>
  119. <div id="mult"></div>
  120. <div id="hud" class="layer">
  121. <div class="pill"><div class="k">余额</div><div class="v" id="bal">5000</div></div>
  122. <div class="pill"><div class="k">下注</div><div class="v" id="bet">50</div></div>
  123. <div class="pill"><div class="k">本局赢</div><div class="v" id="win">0</div></div>
  124. </div>
  125. <div id="ctrl" class="layer">
  126. <div class="cbtn"><button class="rb small" id="turbo"><img><span>⚡</span></button><div class="lab" id="turboLab">TURBO</div></div>
  127. <div class="cbtn"><button class="rb small" id="minus"><img><span>−</span></button><div class="lab">BET</div></div>
  128. <img id="spin">
  129. <div class="cbtn"><button class="rb small" id="plus"><img><span>+</span></button><div class="lab">BET</div></div>
  130. <div class="cbtn"><button class="rb small" id="auto"><img><span>▶</span></button><div class="lab" id="autoLab">AUTO</div></div>
  131. </div>
  132. <div id="coins"></div>
  133. <div class="hint">点 ⟳ 旋转 · 同款果冻聚成一片就连锁消除,连锁越多倍数越高</div>
  134. </div>
  135. <script>
  136. const A = __ASSETS__;
  137. const SYMBOLS = __SYMBOLS__;
  138. const COLS = 5, ROWS = 7, MIN_MATCH = 7, MAX_CASCADE = 8, START = 5000;
  139. let bet = 50, balance = START, multiplier = 1, roundWin = 0, spinning = false;
  140. let cascade = 0, turbo = false, auto = false;
  141. const $ = s => document.querySelector(s);
  142. // 贴静态美术
  143. $('#bg').src = A.bg;
  144. $('#logo img').src = A.logo;
  145. $('#frameImg').src = A.frame;
  146. $('#spin').src = A.spin;
  147. document.querySelectorAll('.rb img').forEach(i => i.src = A.round);
  148. // 建网格
  149. const grid = $('#grid');
  150. const ids = []; // ids[c][r]
  151. const cellEls = []; // cellEls[c][r]
  152. const rand = () => SYMBOLS[Math.floor(Math.random()*SYMBOLS.length)];
  153. for (let r=0;r<ROWS;r++){
  154. for (let c=0;c<COLS;c++){
  155. const el = document.createElement('div'); el.className='cell';
  156. const img = document.createElement('img');
  157. el.appendChild(img); grid.appendChild(el);
  158. (cellEls[c]=cellEls[c]||[])[r]=el; (ids[c]=ids[c]||[])[r]=null;
  159. }
  160. }
  161. function setSym(c,r,id,drop){
  162. ids[c][r]=id; const el=cellEls[c][r]; const img=el.querySelector('img');
  163. img.src=A[id]; el.classList.remove('pop');
  164. if(drop){ el.classList.remove('drop'); void el.offsetWidth; el.classList.add('drop'); }
  165. }
  166. for(let c=0;c<COLS;c++) for(let r=0;r<ROWS;r++) setSym(c,r,rand(),false);
  167. const fmt = n => Math.floor(n).toLocaleString();
  168. function setHud(){ $('#bet').textContent=bet; $('#win').textContent=fmt(roundWin); }
  169. function animBalance(){
  170. const cur=parseInt($('#bal').textContent.replace(/,/g,''))||0;
  171. const step=(balance-cur)/8;
  172. if(Math.abs(balance-cur)<1){ $('#bal').textContent=fmt(balance); return; }
  173. $('#bal').textContent=fmt(cur+step); requestAnimationFrame(animBalance);
  174. }
  175. const T = () => turbo?0.45:1; // turbo 缩短节奏
  176. function spin(){
  177. if(spinning) return;
  178. if(balance<bet){ flash('余额不足'); return; }
  179. spinning=true; $('#spin').classList.add('spinning');
  180. balance-=bet; animBalance(); multiplier=1; roundWin=0; cascade=0; setHud();
  181. for(let c=0;c<COLS;c++) for(let r=0;r<ROWS;r++) setSym(c,r,rand(),true);
  182. setTimeout(resolve, 420*T());
  183. }
  184. function resolve(){
  185. const count={};
  186. for(let c=0;c<COLS;c++) for(let r=0;r<ROWS;r++){const id=ids[c][r];count[id]=(count[id]||0)+1;}
  187. const winners=Object.keys(count).filter(k=>count[k]>=MIN_MATCH);
  188. if(!winners.length){ endRound(); return; }
  189. let cleared=0;
  190. for(let c=0;c<COLS;c++) for(let r=0;r<ROWS;r++)
  191. if(winners.includes(ids[c][r])){ cellEls[c][r].classList.add('pop'); cleared++; }
  192. const pay=Math.floor(cleared*(bet/MIN_MATCH)*multiplier);
  193. roundWin+=pay; balance+=pay; setHud(); animBalance();
  194. flash('x'+multiplier+' +'+pay); coinRain(Math.min(18,cleared));
  195. cascade++;
  196. setTimeout(()=>{
  197. for(let c=0;c<COLS;c++) for(let r=0;r<ROWS;r++)
  198. if(winners.includes(ids[c][r])) setSym(c,r,rand(),true);
  199. multiplier++;
  200. if(cascade>=MAX_CASCADE){ endRound(); return; } // 连锁封顶,确保收束
  201. setTimeout(resolve, 360*T());
  202. }, 520*T());
  203. }
  204. function endRound(){
  205. if(roundWin>0) flash('总赢 +'+fmt(roundWin));
  206. spinning=false; $('#spin').classList.remove('spinning');
  207. if(auto && balance>=bet) setTimeout(spin, 600*T());
  208. }
  209. function flash(t){
  210. const m=$('#mult'); m.textContent=t; m.classList.remove('show'); void m.offsetWidth; m.classList.add('show');
  211. }
  212. function coinRain(n){
  213. const box=$('#coins');
  214. for(let i=0;i<n;i++){
  215. const c=document.createElement('img'); c.src=A['symbol_coin']||A[SYMBOLS[0]]; c.className='coin';
  216. c.style.left=(10+Math.random()*80)+'%'; c.style.top='28%';
  217. box.appendChild(c);
  218. const dx=(Math.random()*2-1)*30, dur=0.9+Math.random()*0.6, rot=(Math.random()*2-1)*360;
  219. c.animate([{transform:'translate(0,0) rotate(0)',opacity:1},
  220. {transform:`translate(${dx}vw,70vh) rotate(${rot}deg)`,opacity:.2}],
  221. {duration:dur*1000,easing:'cubic-bezier(.4,0,.7,1)'}).onfinish=()=>c.remove();
  222. }
  223. }
  224. $('#spin').onclick=spin;
  225. $('#minus').onclick=()=>{ if(spinning)return; bet=Math.max(10,bet-10); setHud(); };
  226. $('#plus').onclick=()=>{ if(spinning)return; bet=Math.min(500,bet+10); setHud(); };
  227. $('#turbo').onclick=()=>{ turbo=!turbo; $('#turboLab').classList.toggle('on',turbo); };
  228. $('#auto').onclick=()=>{ auto=!auto; $('#autoLab').classList.toggle('on',auto); if(auto&&!spinning) spin(); };
  229. setHud();
  230. </script>
  231. </body>
  232. </html>
  233. """
  234. html = HTML.replace("__ASSETS__", ASSETS).replace("__SYMBOLS__", SYMS)
  235. os.makedirs(os.path.dirname(OUT), exist_ok=True)
  236. with open(OUT, "w", encoding="utf-8") as f:
  237. f.write(html)
  238. print("wrote", OUT, f"({len(html)//1024} KB)")