@@ -0,0 +1,855 @@
<!DOCTYPE html>
< html lang = "zh-CN" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< title > TamaBox 主题生成器< / title >
< script > window . addEventListener ( "error" , e => { const p = document . createElement ( "pre" ) ; p . id = "jserr" ; p . textContent = ( e . message || "" ) + " @ " + ( e . lineno || 0 ) + ":" + ( e . colno || 0 ) + "\n" + ( window . _ _stage || "-" ) + "\n" + ( e . error && e . error . stack || "" ) ; document . documentElement . appendChild ( p ) ; } ) ; < / script >
< style >
: root {
--ui-bg : #0f1216 ; --ui-panel : #171c23 ; --ui-panel2 : #1e242d ; --ui-line : #2a323d ;
--ui-text : #e6ebf2 ; --ui-text2 : #9aa7b8 ; --ui-accent : #5c9bff ; --ui-ok : #3ecf8e ; --ui-warn : #f2b544 ;
--ui-radius : 10 px ;
font-family : system-ui , - apple-system , "Segoe UI" , "PingFang SC" , "Microsoft YaHei" , sans-serif ;
}
* { box-sizing : border-box }
body { margin : 0 ; background : var ( - - ui - bg ) ; color : var ( - - ui - text ) ; font-size : 13 px ; line-height : 1.6 }
header { display : flex ; align-items : center ; gap : 14 px ; padding : 12 px 18 px ; border-bottom : 1 px solid var ( - - ui - line ) ; background : var ( - - ui - panel ) ; position : sticky ; top : 0 ; z-index : 20 }
header h1 { font-size : 14 px ; margin : 0 ; font-weight : 600 ; letter-spacing : .02 em }
header . hint { color : var ( - - ui - text2 ) ; font-size : 12 px }
header . right { margin-left : auto ; display : flex ; gap : 8 px ; align-items : center }
main { display : grid ; grid-template-columns : 340 px minmax ( 0 , 1 fr ) ; height : calc ( 100 vh - 53 px ) }
. panel { overflow-y : auto ; border-right : 1 px solid var ( - - ui - line ) ; background : var ( - - ui - panel ) ; padding : 6 px 0 40 px }
section { border-bottom : 1 px solid var ( - - ui - line ) ; padding : 12 px 16 px }
section h2 { font-size : 12 px ; margin : 0 0 10 px ; color : var ( - - ui - text2 ) ; font-weight : 600 ; letter-spacing : .08 em ; text-transform : uppercase }
label { display : block ; font-size : 12 px ; color : var ( - - ui - text2 ) ; margin : 8 px 0 4 px }
input [ type = text ] , input [ type = number ] , select { width : 100 % ; background : var ( - - ui - panel2 ) ; border : 1 px solid var ( - - ui - line ) ; color : var ( - - ui - text ) ; border-radius : 6 px ; padding : 6 px 8 px ; font-size : 12 px }
input [ type = range ] { width : 100 % ; accent-color : var ( - - ui - accent ) }
input [ type = color ] { width : 100 % ; height : 28 px ; background : none ; border : 1 px solid var ( - - ui - line ) ; border-radius : 6 px ; padding : 2 px }
. row { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 8 px }
. row3 { display : grid ; grid-template-columns : 1 fr 1 fr 1 fr ; gap : 8 px }
button { background : var ( - - ui - panel2 ) ; border : 1 px solid var ( - - ui - line ) ; color : var ( - - ui - text ) ; border-radius : 7 px ; padding : 7 px 10 px ; font-size : 12 px ; cursor : pointer }
button : hover { border-color : #3b4757 }
button . primary { background : var ( - - ui - accent ) ; border-color : var ( - - ui - accent ) ; color : #06121f ; font-weight : 600 }
button . ghost { background : transparent }
. drop { border : 1 px dashed #3a4553 ; border-radius : 8 px ; padding : 10 px ; text-align : center ; color : var ( - - ui - text2 ) ; cursor : pointer ; font-size : 12 px }
. drop : hover { border-color : var ( - - ui - accent ) ; color : var ( - - ui - text ) }
. drop . filled { border-style : solid ; border-color : #2f6b4d ; color : var ( - - ui - ok ) }
. thumbs { display : flex ; gap : 8 px ; flex-wrap : wrap ; margin-top : 8 px }
. thumb { position : relative ; width : 64 px ; height : 64 px ; border-radius : 6 px ; overflow : hidden ; background : #0b0e12 ; border : 1 px solid var ( - - ui - line ) }
. thumb img { width : 100 % ; height : 100 % ; object-fit : contain }
. meta { font-size : 11 px ; color : var ( - - ui - text2 ) ; margin-top : 6 px }
. stage { display : flex ; flex-direction : column ; overflow : hidden ; background : #0b0e12 ; min-width : 0 }
. stagebar { display : flex ; gap : 8 px ; align-items : center ; padding : 10 px 14 px ; border-bottom : 1 px solid var ( - - ui - line ) ; background : var ( - - ui - panel ) }
. stagebar . seg { display : flex ; border : 1 px solid var ( - - ui - line ) ; border-radius : 7 px ; overflow : hidden }
. stagebar . seg button { border : none ; border-radius : 0 ; padding : 5 px 10 px ; background : transparent }
. stagebar . seg button . on { background : var ( - - ui - accent ) ; color : #06121f ; font-weight : 600 }
. stagebar . grow { flex : 1 }
. canvas { flex : 1 ; overflow : auto ; padding : 18 px ; display : flex ; justify-content : center ; align-items : flex-start ; min-width : 0 ; min-height : 0 }
. frame { position : relative ; overflow : hidden ; flex : 0 0 auto }
/* ---------- 预览页:尽量复刻真实结构,令牌与层序和 base.css 一致 ---------- */
. page {
position : relative ; width : 1512 px ; height : 900 px ; overflow : hidden ; color : var ( - - tb - text ) ;
transform-origin : top left ;
background-color : var ( - - tb - bg ) ;
background-image : var ( - - tb - page - pattern ) , var ( - - tb - deco ) , var ( - - tb - page - image ) ;
background-size : var ( - - tb - page - pattern - size ) , var ( - - tb - deco - size ) , cover ;
background-repeat : repeat , no-repeat , no-repeat , no-repeat , no-repeat , no-repeat , no-repeat , no-repeat ;
background-position : left top , var ( - - tb - deco - position ) , center center ;
background-attachment : scroll , fixed , fixed , fixed , fixed , fixed , fixed , fixed ;
}
. page . nav { height : 60 px ; display : flex ; align-items : center ; gap : 18 px ; padding : 0 24 px ; background : var ( - - tb - navbar ) ; border-bottom : 1 px solid var ( - - tb - border ) }
. page . brand { font-weight : 700 ; letter-spacing : .04 em }
. page . nav a { color : var ( - - tb - text - secondary ) ; text-decoration : none ; font-size : 13 px }
. page . nav . spacer { flex : 1 }
. page . btn { background : var ( - - tb - primary ) ; color : #fff ; border : none ; border-radius : 8 px ; padding : 7 px 14 px ; font-size : 13 px }
. page . btn . ghost { background : transparent ; color : var ( - - tb - text - secondary ) ; border : 1 px solid var ( - - tb - border ) }
. page . wrap { width : 750 px ; margin : 24 px auto 0 }
. page . card { background : var ( - - tb - surface ) ; border : 1 px solid var ( - - tb - border ) ; border-radius : var ( - - tb - radius ) ; box-shadow : var ( - - tb - shadow ) ; padding : 18 px 20 px ; margin-bottom : 14 px }
. page . hero { display : flex ; gap : 14 px ; align-items : center }
. page . avatar { width : 56 px ; height : 56 px ; border-radius : 50 % ; background : var ( - - tb - primary - weak ) ; border : 1 px solid var ( - - tb - border ) }
. page . name { font-size : 16 px ; font-weight : 600 }
. page . meta { font-size : 12 px ; color : var ( - - tb - text - muted ) ; margin-top : 2 px }
. page . chips { display : flex ; gap : 8 px ; margin : 14 px 0 2 px ; flex-wrap : wrap }
. page . chip { font-size : 12 px ; padding : 3 px 10 px ; border-radius : 999 px ; background : var ( - - tb - surface -2 ) ; color : var ( - - tb - text - secondary ) ; border : 1 px solid var ( - - tb - border ) }
. page . chip . on { background : var ( - - tb - primary - weak ) ; color : var ( - - tb - primary ) ; border-color : transparent }
. page . q { padding : 12 px 0 ; border-top : 1 px solid var ( - - tb - border ) }
. page . q p { margin : 0 ; font-size : 13 px }
. page . q time { font-size : 11 px ; color : var ( - - tb - text - muted ) }
. page . ans { margin-top : 8 px ; padding : 10 px 12 px ; border-radius : 8 px ; background : var ( - - tb - surface -2 ) ; font-size : 13 px ; color : var ( - - tb - text - secondary ) }
. page . foot { width : 750 px ; margin : 18 px auto 0 ; display : flex ; gap : 14 px ; justify-content : flex-end ; font-size : 11 px ; color : var ( - - tb - text - muted ) ; padding-bottom : 24 px }
. page a { color : var ( - - tb - primary ) }
/* 拖拽热区 */
. handle { position : absolute ; border : 1 px dashed transparent ; border-radius : 6 px ; cursor : move ; z-index : 5 }
. handle : hover { border-color : rgba ( 92 , 155 , 255 , .85 ) ; background : rgba ( 92 , 155 , 255 , .10 ) }
. handle . tag { position : absolute ; left : 0 ; top : -18 px ; font-size : 11 px ; color : var ( - - ui - accent ) ; opacity : 0 ; white-space : nowrap }
. handle : hover . tag { opacity : 1 }
. out { flex : 0 0 auto ; padding : 8 px 18 px 12 px ; font-size : 11 px ; color : var ( - - ui - text2 ) ; white-space : pre-wrap ; word-break : break-all ; min-height : 18 px }
. empty { position : absolute ; inset : 0 ; display : flex ; flex-direction : column ; gap : 6 px ; align-items : center ; justify-content : center ;
color : #7c8a9c ; font-size : 13 px ; pointer-events : none ; z-index : 6 ; text-align : center }
. empty b { color : #a9b8ca ; font-weight : 600 }
. bad { color : #ff7676 }
. ok { color : var ( - - ui - ok ) }
< / style >
< / head >
< body >
< header >
< h1 > TamaBox · 主题生成器< / h1 >
< span class = "hint" > 选图 → 调位置 → 导出 zip,解压到项目根目录即可< / span >
< div class = "right" >
< button id = "btn-demo" class = "ghost" > 载入示例< / button >
< button id = "btn-export" class = "primary" > 导出 zip< / button >
< / div >
< / header >
< main >
< aside class = "panel" >
< section >
< h2 > 1 · 基本< / h2 >
< label > slug(小写字母 / 数字 / 连字符,= css 文件名)< / label >
< input type = "text" id = "slug" value = "my-theme" >
< label > 显示名(写进 README,不影响运行)< / label >
< input type = "text" id = "name" value = "我的主题" >
< div class = "meta" > 生成路径:< code > static/css/themes/< slug> .css< / code > < / div >
< / section >
< section >
< h2 > 2 · 图片< / h2 >
< div class = "drop" id = "drop-scene" > 背景图(16:9,会被压成 1920× 1080) < / div >
< div class = "drop" id = "drop-scene-m" > 背景图 · 移动端(可选,竖版)< / div >
< div class = "drop" id = "drop-char" > 人物(透明底 PNG/WebP) < / div >
< div class = "drop" id = "drop-prop" > 道具 / 摆件(可选,透明底)< / div >
< input type = "file" id = "file" accept = "image/*" multiple hidden >
< div class = "thumbs" id = "thumbs" > < / div >
< div class = "meta" id = "img-meta" > 建议:人物底边贴画布底、脚踩地;背景左侧前景留空地。< / div >
< / section >
< section >
< h2 > 3 · 位置< / h2 >
< label > 人物高度 < output id = "o-charh" > 400< / output > px< / label >
< input type = "range" id = "charh" min = "160" max = "640" value = "400" step = "10" >
< div class = "row" >
< div > < label > 离底边 < output id = "o-charb" > 36< / output > px< / label > < input type = "range" id = "charb" min = "-40" max = "200" value = "36" step = "2" > < / div >
< div > < label > 左右微调 < output id = "o-chardx" > 0< / output > < / label > < input type = "range" id = "chardx" min = "-200" max = "200" value = "0" step = "2" > < / div >
< / div >
< label > 人物接触阴影 < input type = "range" id = "charsh" min = "0" max = "60" value = "30" step = "2" > < / label >
< label style = "margin-top:14px" > < input type = "checkbox" id = "prop-on" checked > 启用道具< / label >
< label > 道具高度 < output id = "o-proph" > 259< / output > px< / label >
< input type = "range" id = "proph" min = "80" max = "520" value = "259" step = "1" >
< div class = "row" >
< div > < label > 离底边 < output id = "o-propb" > 20< / output > px< / label > < input type = "range" id = "propb" min = "-40" max = "200" value = "20" step = "2" > < / div >
< div > < label > 相对人物左移 < output id = "o-propdx" > 110< / output > px< / label > < input type = "range" id = "propdx" min = "-200" max = "400" value = "110" step = "2" > < / div >
< / div >
< div class = "meta" > 道具默认放在人物身后(层级更低),用「左移」让它从人物身侧露出来。< / div >
< / section >
< section >
< h2 > 4 · 光与雾< / h2 >
< label > 窗光强度 < output id = "o-beam" > 0.50< / output > < / label >
< input type = "range" id = "beam" min = "0" max = "1" value = "0.5" step = "0.05" >
< label > 地面光斑 < output id = "o-pool" > 0.80< / output > < / label >
< input type = "range" id = "pool" min = "0" max = "1" value = "0.8" step = "0.05" >
< label > 底部压深 < output id = "o-vig" > 0.10< / output > < / label >
< input type = "range" id = "vig" min = "0" max = "0.4" value = "0.1" step = "0.02" >
< div class = "meta" > 背景压得越白(≈235~245),光斑越要轻;纯白叠纯白等于没画。< / div >
< / section >
< section >
< h2 > 5 · 配色< / h2 >
< div class = "row" >
< div > < label > 页面底色< / label > < input type = "color" id = "c-bg" value = "#eef3f9" > < / div >
< div > < label > 主色< / label > < input type = "color" id = "c-primary" value = "#4a7fc1" > < / div >
< / div >
< div class = "row" >
< div > < label > 卡片底< / label > < input type = "color" id = "c-surface" value = "#ffffff" > < / div >
< div > < label > 正文色< / label > < input type = "color" id = "c-text" value = "#222d3b" > < / div >
< / div >
< label style = "margin-top:10px" > < input type = "checkbox" id = "auto-dark" checked > 自动推导深色配色< / label >
< div class = "meta" > 取消勾选后可手动改深色:< / div >
< div class = "row" id = "dark-row" style = "opacity:.45;pointer-events:none" >
< div > < label > 深色底< / label > < input type = "color" id = "c-dbg" value = "#141a22" > < / div >
< div > < label > 深色主色< / label > < input type = "color" id = "c-dprimary" value = "#6fa3e0" > < / div >
< / div >
< / section >
< / aside >
< section class = "stage" style = "padding:0;border:none" >
< div class = "stagebar" >
< div class = "seg" id = "seg-w" >
< button data-w = "1512" class = "on" > 1512< / button >
< button data-w = "1280" > 1280< / button >
< button data-w = "1024" > 1024< / button >
< / div >
< div class = "seg" id = "seg-theme" >
< button data-theme = "light" class = "on" > 浅色< / button >
< button data-theme = "dark" > 深色< / button >
< / div >
< span class = "grow" > < / span >
< span class = "hint" id = "stage-hint" > 拖虚线框调整位置< / span >
< / div >
< div class = "canvas" id = "canvas" >
< div class = "frame" id = "frame" >
< div class = "page" id = "page" >
< div class = "nav" >
< span class = "brand" > TamaBox< / span >
< a href = "#" > 探索< / a > < a href = "#" > 规则< / a >
< span class = "spacer" > < / span >
< button class = "btn ghost" > 登录< / button > < button class = "btn" > 注册< / button >
< / div >
< div class = "wrap" >
< div class = "card hero" >
< div class = "avatar" > < / div >
< div > < div class = "name" > 狼的提问箱< / div > < div class = "meta" > @admin6 · 收到 128 个问题 · 回答 96 个< / div > < / div >
< / div >
< div class = "chips" > < span class = "chip on" > 全部< / span > < span class = "chip" > 已答< / span > < span class = "chip" > 待答< / span > < / div >
< div class = "card" >
< div class = "q" > < p > 最近在看什么书?< / p > < time > 2 小时前< / time >
< div class = "ans" > 《雪国》——冷得像今天的风。< / div > < / div >
< div class = "q" > < p > 怎么才能不被议题绑架?< / p > < time > 5 小时前< / time > < / div >
< div class = "q" > < p > 有推荐的通勤耳机吗< / p > < time > 昨天< / time > < / div >
< / div >
< / div >
< div class = "foot" > < span > 社区规则< / span > < span > 备案号< / span > < span > 已运行 412 天< / span > < / div >
< div class = "empty" id = "empty" > < b > 先上传一张背景图< / b > < span > 或点右上角「载入示例」先看效果< / span > < / div >
< div class = "handle" id = "h-char" > < span class = "tag" > 人物< / span > < / div >
< div class = "handle" id = "h-prop" > < span class = "tag" > 道具< / span > < / div >
< / div >
< / div >
< / div >
< div class = "out" id = "out" > < / div >
< / section >
< / main >
< input type = "file" id = "pick" accept = "image/*" hidden >
< script >
'use strict' ;
/* =========================================================================
状态
========================================================================= */
const S = {
slug : 'my-theme' ,
name : '我的主题' ,
viewport : 1512 ,
dark : false ,
scene : null , // {url,w,h,kb,blob}
sceneMobile : null ,
char : null ,
prop : null ,
pos : { charH : 400 , charB : 36 , charDx : 0 , charShadow : 30 ,
propH : 259 , propB : 20 , propDx : 110 , propOn : true } ,
air : { beam : 0.5 , pool : 0.8 , vig : 0.10 } ,
colors : { bg : '#eef3f9' , primary : '#4a7fc1' , surface : '#ffffff' , text : '#222d3b' ,
dbg : '#141a22' , dprimary : '#6fa3e0' , autoDark : true }
} ;
const $ = ( id ) => document . getElementById ( id ) ;
const elPage = $ ( 'page' ) , elFrame = $ ( 'frame' ) , elCanvas = $ ( 'canvas' ) ;
/* =========================================================================
小工具
========================================================================= */
const clamp = ( v , a , b ) => Math . min ( b , Math . max ( a , v ) ) ;
const hex2rgb = ( h ) => { h = h . replace ( '#' , '' ) ; if ( h . length === 3 ) h = h . split ( '' ) . map ( c => c + c ) . join ( '' ) ;
return [ parseInt ( h . slice ( 0 , 2 ) , 16 ) , parseInt ( h . slice ( 2 , 4 ) , 16 ) , parseInt ( h . slice ( 4 , 6 ) , 16 ) ] ; } ;
const rgb2hex = ( r , g , b ) => '#' + [ r , g , b ] . map ( v => clamp ( Math . round ( v ) , 0 , 255 ) . toString ( 16 ) . padStart ( 2 , '0' ) ) . join ( '' ) ;
const rgba = ( h , a ) => ` rgba( ${ hex2rgb ( h ) . join ( ', ' ) } , ${ a } ) ` ;
// 亮度权重(用于自动推导深色配色)
function shade ( hex , f ) {
const [ r , g , b ] = hex2rgb ( hex ) ;
if ( f >= 0 ) return rgb2hex ( r + ( 255 - r ) * f , g + ( 255 - g ) * f , b + ( 255 - b ) * f ) ;
return rgb2hex ( r * ( 1 + f ) , g * ( 1 + f ) , b * ( 1 + f ) ) ;
}
function mix ( a , b , t ) { const A = hex2rgb ( a ) , B = hex2rgb ( b ) ; return rgb2hex ( A [ 0 ] + ( B [ 0 ] - A [ 0 ] ) * t , A [ 1 ] + ( B [ 1 ] - A [ 1 ] ) * t , A [ 2 ] + ( B [ 2 ] - A [ 2 ] ) * t ) ; }
/* =========================================================================
图片处理:上传 → 目标尺寸 WebP
========================================================================= */
async function loadImg ( file ) {
const url = URL . createObjectURL ( file ) ;
const img = new Image ( ) ;
await new Promise ( ( res , rej ) => { img . onload = res ; img . onerror = rej ; img . src = url ; } ) ;
return img ;
}
// canvas → {dataURL, bytes}:同步完成,导出时不用等 Promise
function canvasAsset ( cv , q ) {
const url = cv . toDataURL ( 'image/webp' , q ) ;
const b64 = url . split ( ',' ) [ 1 ] || '' ;
const bin = atob ( b64 ) ;
const bytes = new Uint8Array ( bin . length ) ;
for ( let i = 0 ; i < bin . length ; i ++ ) bytes [ i ] = bin . charCodeAt ( i ) ;
return { url , bytes , w : cv . width , h : cv . height , kb : Math . round ( bytes . length / 1024 ) } ;
}
async function webpFrom ( file , opt ) {
const img = await loadImg ( file ) ;
const { mode = 'cover' , w = 0 , h = 0 , q = 0.86 , shadow = 0 } = opt ;
// contain:画布按内容实际尺寸裁(否则会留下大片透明边,导出后宽高比全错)
let cw , chh , scale ;
if ( mode === 'cover' ) {
scale = Math . max ( w / img . width , h / img . height ) ; cw = w ; chh = h ;
} else {
scale = Math . min ( h / img . height , ( w || 1e9 ) / img . width ) ;
cw = Math . round ( img . width * scale ) ; chh = Math . round ( img . height * scale ) ;
}
const cv = document . createElement ( 'canvas' ) ;
cv . width = cw ; cv . height = chh ;
const ctx = cv . getContext ( '2d' ) ;
const dw = img . width * scale , dh = img . height * scale ;
const dx = ( cw - dw ) / 2 , dy = ( chh - dh ) / 2 ;
if ( shadow > 0 ) { // 道具的接触阴影烘进图里
ctx . save ( ) ;
ctx . filter = ` blur( ${ Math . round ( shadow * 0.45 ) } px) ` ;
ctx . fillStyle = 'rgba(70,88,112,0.42)' ;
ctx . beginPath ( ) ;
ctx . ellipse ( cw / 2 , chh - Math . round ( shadow * 0.55 ) , cw * 0.42 , shadow * 0.5 , 0 , 0 , Math . PI * 2 ) ;
ctx . fill ( ) ;
ctx . restore ( ) ;
}
ctx . drawImage ( img , dx , dy , dw , dh ) ;
return canvasAsset ( cv , q ) ;
}
/* =========================================================================
配色(浅/深两套,10 个必需令牌 + navbar / radius / shadow)
========================================================================= */
function palette ( ) {
const c = S . colors ;
const light = {
bg : c . bg , surface : c . surface , surface2 : mix ( c . bg , c . surface , 0.5 ) ,
surfaceHover : mix ( c . surface , c . primary , 0.06 ) ,
navbar : rgba ( c . bg , 0.92 ) ,
border : mix ( c . bg , c . text , 0.12 ) ,
text : c . text ,
text2 : mix ( c . text , c . bg , 0.35 ) ,
text3 : mix ( c . text , c . bg , 0.58 ) ,
primary : c . primary ,
primaryWeak : rgba ( c . primary , 0.11 ) ,
} ;
let dark ;
if ( c . autoDark ) {
const bg2 = mix ( '#101418' , c . bg , 0.12 ) ;
dark = {
bg : bg2 , surface : shade ( bg2 , 0.05 ) , surface2 : shade ( bg2 , 0.09 ) ,
surfaceHover : rgba ( shade ( c . primary , 0.15 ) , 0.12 ) ,
navbar : bg2 ,
border : shade ( bg2 , 0.14 ) ,
text : mix ( '#ffffff' , light . text , 0.10 ) ,
text2 : mix ( '#ffffff' , light . text2 , 0.35 ) ,
text3 : mix ( '#ffffff' , light . text3 , 0.55 ) ,
primary : shade ( c . primary , 0.22 ) ,
primaryWeak : rgba ( shade ( c . primary , 0.22 ) , 0.16 ) ,
} ;
} else {
dark = { bg : c . dbg , surface : shade ( c . dbg , 0.05 ) , surface2 : shade ( c . dbg , 0.09 ) ,
surfaceHover : rgba ( c . dprimary , 0.12 ) , navbar : c . dbg , border : shade ( c . dbg , 0.14 ) ,
text : '#e3eaf4' , text2 : mix ( '#e3eaf4' , c . dbg , 0.35 ) , text3 : mix ( '#e3eaf4' , c . dbg , 0.55 ) ,
primary : c . dprimary , primaryWeak : rgba ( c . dprimary , 0.16 ) } ;
}
return { light , dark } ;
}
/* =========================================================================
背景装饰层(固定 6 槽位:人 / 人影 / 道具 / 光柱 / 光斑 / 压深)
注意:background-position 的百分比相对 (容器宽 - 图宽),所以这里把
设计稿里的「左沿像素」反解成 calc(50% - Npx) 的 N。
========================================================================= */
function charDims ( ) {
if ( ! S . char ) return { w : 0 , h : S . pos . charH } ;
return { w : Math . round ( S . char . w * S . pos . charH / S . char . h ) , h : S . pos . charH } ;
}
function propDims ( ) {
if ( ! S . prop ) return { w : 0 , h : S . pos . propH } ;
return { w : Math . round ( S . prop . w * S . pos . propH / S . prop . h ) , h : S . pos . propH } ;
}
// 设计稿里人物左沿:居中束左边界再往里退 90px(与卡片留白一致)
function baseLeft ( ) { return ( S . viewport - 750 ) / 2 - 90 - charDims ( ) . w ; }
function offsetN ( leftPx , wImg ) { return Math . round ( ( S . viewport - wImg ) / 2 - leftPx ) ; }
function decoCSS ( theme ) {
const p = palette ( ) [ theme ] ;
const c = charDims ( ) , pr = propDims ( ) ;
const layers = [ ] , sizes = [ ] , poss = [ ] ;
if ( S . char ) {
const left = baseLeft ( ) + S . pos . charDx ;
layers . push ( ` url("IMAGES/character.webp") ` ) ;
sizes . push ( ` auto ${ S . pos . charH } px ` ) ;
poss . push ( ` left calc(50% - ${ offsetN ( left , c . w ) } px) bottom ${ S . pos . charB } px ` ) ;
if ( S . pos . charShadow > 0 ) {
layers . push ( ` radial-gradient(ellipse at center, rgba(63, 96, 138, ${ ( S . pos . charShadow / 100 ) . toFixed ( 2 ) } ) 0%, rgba(63, 96, 138, 0) 72%) ` ) ;
sizes . push ( ` ${ Math . round ( c . w * 0.72 ) } px ${ Math . round ( c . w * 0.11 ) } px ` ) ;
poss . push ( ` left calc(50% - ${ offsetN ( left + Math . round ( c . w * 0.14 ) , Math . round ( c . w * 0.72 ) ) } px) bottom ${ Math . max ( 0 , S . pos . charB - 7 ) } px ` ) ;
} else { layers . push ( 'none' ) ; sizes . push ( 'auto' ) ; poss . push ( 'left top' ) ; }
} else {
layers . push ( 'none' ) ; sizes . push ( 'auto' ) ; poss . push ( 'left top' ) ;
layers . push ( 'none' ) ; sizes . push ( 'auto' ) ; poss . push ( 'left top' ) ;
}
if ( S . prop && S . pos . propOn ) {
const left = baseLeft ( ) + S . pos . charDx - S . pos . propDx ;
layers . push ( ` url("IMAGES/prop.webp") ` ) ;
sizes . push ( ` auto ${ S . pos . propH } px ` ) ;
poss . push ( ` left calc(50% - ${ offsetN ( left , pr . w ) } px) bottom ${ S . pos . propB } px ` ) ;
} else { layers . push ( 'none' ) ; sizes . push ( 'auto' ) ; poss . push ( 'left top' ) ; }
const beam = S . air . beam , pool = S . air . pool , vig = S . air . vig ;
layers . push ( ` linear-gradient(112deg, rgba(255, 250, 236, ${ ( beam * 0.5 ) . toFixed ( 2 ) } ) 0%, rgba(255, 250, 236, ${ ( beam * 0.22 ) . toFixed ( 2 ) } ) 20%, rgba(255, 250, 236, 0) 44%) ` ) ;
sizes . push ( '100% 100%' ) ; poss . push ( 'left top' ) ;
layers . push ( ` radial-gradient(ellipse 560px 170px at 28% 94%, rgba(255, 248, 228, ${ pool . toFixed ( 2 ) } ) 0%, rgba(255, 248, 228, 0) 72%) ` ) ;
sizes . push ( '100% 100%' ) ; poss . push ( 'left top' ) ;
layers . push ( ` linear-gradient(0deg, rgba(110, 128, 156, ${ vig . toFixed ( 2 ) } ) 0%, rgba(110, 128, 156, 0) 24%) ` ) ;
sizes . push ( '100% 100%' ) ; poss . push ( 'left top' ) ;
return { layers : layers . join ( ',\n ' ) , sizes : sizes . join ( ', ' ) , positions : poss . join ( ',\n ' ) } ;
}
/* =========================================================================
预览:把令牌与层写进 .page
========================================================================= */
function render ( ) {
const { light , dark } = palette ( ) ;
const t = S . dark ? dark : light ;
const rect = { w : S . viewport , h : 900 } ;
elPage . style . width = rect . w + 'px' ;
elPage . style . height = rect . h + 'px' ;
elPage . style . setProperty ( '--tb-bg' , t . bg ) ;
elPage . style . setProperty ( '--tb-surface' , t . surface ) ;
elPage . style . setProperty ( '--tb-surface-2' , t . surface2 ) ;
elPage . style . setProperty ( '--tb-surface-hover' , t . surfaceHover ) ;
elPage . style . setProperty ( '--tb-navbar' , t . navbar ) ;
elPage . style . setProperty ( '--tb-border' , t . border ) ;
elPage . style . setProperty ( '--tb-text' , t . text ) ;
elPage . style . setProperty ( '--tb-text-secondary' , t . text2 ) ;
elPage . style . setProperty ( '--tb-text-muted' , t . text3 ) ;
elPage . style . setProperty ( '--tb-primary' , t . primary ) ;
elPage . style . setProperty ( '--tb-primary-weak' , t . primaryWeak ) ;
elPage . style . setProperty ( '--tb-radius' , '12px' ) ;
elPage . style . setProperty ( '--tb-radius-sm' , '8px' ) ;
elPage . style . setProperty ( '--tb-radius-xs' , '6px' ) ;
elPage . style . setProperty ( '--tb-shadow' , '0 1px 2px rgba(16,24,40,.05), 0 18px 44px -22px rgba(38,56,82,.30)' ) ;
elPage . style . setProperty ( '--tb-shadow-hover' , '0 1px 2px rgba(16,24,40,.07), 0 24px 56px -24px rgba(38,56,82,.38)' ) ;
// 深色不加载图片(和导出结果一致:只有浅色块用图)
const darkBg = ` linear-gradient(180deg, ${ shade ( t . bg , 0.03 ) } 0%, ${ t . bg } 60%, ${ shade ( t . bg , - 0.03 ) } 100%) ` ;
elPage . style . setProperty ( '--tb-page-image' , S . dark ? darkBg : ( S . scene ? ` url(" ${ S . scene . url } ") ` : 'none' ) ) ;
elPage . style . setProperty ( '--tb-page-pattern' , 'none' ) ;
elPage . style . setProperty ( '--tb-page-pattern-size' , 'auto' ) ;
const d = decoCSS ( S . dark ? 'dark' : 'light' ) ;
// 预览里把 IMAGES/ 占位换成本地 objectURL
if ( S . dark ) {
// 深色块里 deco 恒为 none(导出也是这样),避免白天插画压深底
elPage . style . setProperty ( '--tb-deco' , 'none, none, none, none, none, none' ) ;
elPage . style . setProperty ( '--tb-deco-size' , 'auto, auto, auto, auto, auto, auto' ) ;
elPage . style . setProperty ( '--tb-deco-position' , 'left top, left top, left top, left top, left top, left top' ) ;
} else {
const dl = d . layers
. replace ( 'url("IMAGES/character.webp")' , S . char ? ` url(" ${ S . char . url } ") ` : 'none' )
. replace ( 'url("IMAGES/prop.webp")' , S . prop ? ` url(" ${ S . prop . url } ") ` : 'none' ) ;
elPage . style . setProperty ( '--tb-deco' , dl ) ;
elPage . style . setProperty ( '--tb-deco-size' , d . sizes ) ;
elPage . style . setProperty ( '--tb-deco-position' , d . positions ) ;
}
$ ( 'empty' ) . style . display = S . scene ? 'none' : 'flex' ;
syncHandles ( ) ;
fitStage ( ) ;
}
function syncHandles ( ) {
const c = charDims ( ) , pr = propDims ( ) ;
const left = baseLeft ( ) + S . pos . charDx ;
const hc = $ ( 'h-char' ) ;
if ( S . char ) {
hc . style . display = 'block' ;
hc . style . left = left + 'px' ;
hc . style . width = c . w + 'px' ;
hc . style . top = ( 900 - S . pos . charB - c . h ) + 'px' ;
hc . style . height = c . h + 'px' ;
} else hc . style . display = 'none' ;
const hp = $ ( 'h-prop' ) ;
if ( S . prop && S . pos . propOn ) {
const pl = left - S . pos . propDx ;
hp . style . display = 'block' ;
hp . style . left = pl + 'px' ;
hp . style . width = pr . w + 'px' ;
hp . style . top = ( 900 - S . pos . propB - pr . h ) + 'px' ;
hp . style . height = pr . h + 'px' ;
} else hp . style . display = 'none' ;
}
function fitStage ( ) {
const availW = elCanvas . clientWidth - 36 ;
const availH = elCanvas . clientHeight - 36 ;
const k = Math . min ( 1 , availW / S . viewport , availH / 900 ) ;
elPage . style . transform = ` scale( ${ k } ) ` ;
elFrame . style . width = Math . round ( S . viewport * k ) + 'px' ;
elFrame . style . height = Math . round ( 900 * k ) + 'px' ;
elFrame . dataset . k = k ;
}
/* =========================================================================
拖拽
========================================================================= */
function bindDrag ( handle , which ) {
let start = null ;
handle . addEventListener ( 'pointerdown' , e => {
const c = charDims ( ) , pr = propDims ( ) ;
start = { x : e . clientX , y : e . clientY , dx : S . pos . charDx ,
pb : which === 'char' ? S . pos . charB : S . pos . propB ,
pdx : S . pos . propDx } ;
handle . setPointerCapture ( e . pointerId ) ;
e . preventDefault ( ) ;
} ) ;
handle . addEventListener ( 'pointermove' , e => {
if ( ! start ) return ;
const k = parseFloat ( elFrame . dataset . k || '1' ) ;
const dx = ( e . clientX - start . x ) / k , dy = ( e . clientY - start . y ) / k ;
if ( which === 'char' ) {
S . pos . charDx = clamp ( start . dx + dx , - 300 , 300 ) ;
S . pos . charB = clamp ( start . pb - dy , - 40 , 300 ) ;
$ ( 'chardx' ) . value = S . pos . charDx ; $ ( 'o-chardx' ) . value = Math . round ( S . pos . charDx ) ;
$ ( 'charb' ) . value = S . pos . charB ; $ ( 'o-charb' ) . value = Math . round ( S . pos . charB ) ;
} else {
S . pos . propDx = clamp ( start . pdx - dx , - 200 , 500 ) ;
S . pos . propB = clamp ( start . pb - dy , - 40 , 300 ) ;
$ ( 'propdx' ) . value = S . pos . propDx ; $ ( 'o-propdx' ) . value = Math . round ( S . pos . propDx ) ;
$ ( 'propb' ) . value = S . pos . propB ; $ ( 'o-propB' ) . value = Math . round ( S . pos . propB ) ;
$ ( 'o-propb' ) . value = Math . round ( S . pos . propB ) ;
}
render ( ) ;
} ) ;
const end = e => { start = null ; try { handle . releasePointerCapture ( e . pointerId ) ; } catch ( _ ) { } } ;
handle . addEventListener ( 'pointerup' , end ) ;
handle . addEventListener ( 'pointercancel' , end ) ;
}
bindDrag ( $ ( 'h-char' ) , 'char' ) ;
bindDrag ( $ ( 'h-prop' ) , 'prop' ) ;
/* =========================================================================
CSS 生成
========================================================================= */
function buildCSS ( ) {
const { light , dark } = palette ( ) ;
const d = decoCSS ( 'light' ) ;
const box = ( t ) => ` --tb-bg: ${ t . bg } ;
--tb-surface: ${ t . surface } ;
--tb-surface-2: ${ t . surface2 } ;
--tb-surface-hover: ${ t . surfaceHover } ;
--tb-navbar: ${ t . navbar } ;
--tb-border: ${ t . border } ;
--tb-text: ${ t . text } ;
--tb-text-secondary: ${ t . text2 } ;
--tb-text-muted: ${ t . text3 } ;
--tb-primary: ${ t . primary } ;
--tb-primary-weak: ${ t . primaryWeak } ;
--tb-radius: 12px;
--tb-radius-sm: 8px;
--tb-radius-xs: 6px;
--tb-shadow: 0 1px 2px rgba(16, 24, 40, 0.05), 0 18px 44px -22px rgba(38, 56, 82, 0.30);
--tb-shadow-hover: 0 1px 2px rgba(16, 24, 40, 0.07), 0 24px 56px -24px rgba(38, 56, 82, 0.38);
--tb-font: system-ui, -apple-system, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; ` ;
const darkBg = S . scene ? ` url("IMAGES/scene-dark.webp") ` : ` linear-gradient(180deg, ${ shade ( dark . bg , 0.03 ) } 0%, ${ dark . bg } 60%, ${ shade ( dark . bg , - 0.03 ) } 100%) ` ;
return ` /* 由 TamaBox 主题生成器导出: ${ S . name } ( ${ S . slug } )
图片放在 static/images/themes/ ${ S . slug } /,本文件放在 static/css/themes/ ${ S . slug } .css。
新增后记得把 ${ S . slug } 加进 conf/app.ini 的 [theme] available,并重新编译部署。 */
html[data-theme-style=" ${ S . slug } "] {
${ box ( light ) }
--tb-page-image: ${ S . scene ? 'url("IMAGES/scene-desktop.webp")' : 'none' } ;
--tb-page-pattern: none;
--tb-page-pattern-size: auto;
--tb-deco: ${ d . layers } ;
--tb-deco-size: ${ d . sizes } ;
--tb-deco-position: ${ d . positions } ;
}
html[data-theme="dark"][data-theme-style=" ${ S . slug } "] {
${ box ( dark ) }
--tb-page-image: ${ darkBg } ;
--tb-page-pattern: none;
--tb-page-pattern-size: auto;
--tb-deco: none, none, none, none, none, none;
--tb-deco-size: auto, auto, auto, auto, auto, auto;
--tb-deco-position: left top, left top, left top, left top, left top, left top;
}
` ;
}
/* =========================================================================
ZIP( STORE,无依赖)
========================================================================= */
const CRC _TABLE = ( ( ) => { const t = new Uint32Array ( 256 ) ;
for ( let n = 0 ; n < 256 ; n ++ ) { let c = n ; for ( let k = 0 ; k < 8 ; k ++ ) c = c & 1 ? 0xEDB88320 ^ ( c >>> 1 ) : c >>> 1 ; t [ n ] = c >>> 0 ; }
return t ; } ) ( ) ;
function crc32 ( buf ) { let c = 0xFFFFFFFF ; for ( let i = 0 ; i < buf . length ; i ++ ) c = CRC _TABLE [ ( c ^ buf [ i ] ) & 0xFF ] ^ ( c >>> 8 ) ; return ( c ^ 0xFFFFFFFF ) >>> 0 ; }
function u16 ( n ) { return [ n & 255 , ( n >> 8 ) & 255 ] ; }
function u32 ( n ) { return [ n & 255 , ( n >> 8 ) & 255 , ( n >> 16 ) & 255 , ( n >> 24 ) & 255 ] ; }
function zipStore ( files ) {
const enc = new TextEncoder ( ) ;
const parts = [ ] , central = [ ] ;
let offset = 0 ;
const now = new Date ( ) ;
const dosTime = ( ( now . getHours ( ) << 11 ) | ( now . getMinutes ( ) << 5 ) | ( now . getSeconds ( ) / 2 ) ) & 0xFFFF ;
const dosDate = ( ( ( now . getFullYear ( ) - 1980 ) << 9 ) | ( ( now . getMonth ( ) + 1 ) << 5 ) | now . getDate ( ) ) & 0xFFFF ;
for ( const f of files ) {
const data = typeof f . data === 'string' ? enc . encode ( f . data ) : f . data ;
const name = enc . encode ( f . name ) ;
const crc = crc32 ( data ) ;
const lh = [ ] . concat ( u32 ( 0x04034b50 ) , u16 ( 20 ) , u16 ( 0x0800 ) , u16 ( 0 ) , u16 ( dosTime ) , u16 ( dosDate ) ,
u32 ( crc ) , u32 ( data . length ) , u32 ( data . length ) , u16 ( name . length ) , u16 ( 0 ) ) ;
const head = new Uint8Array ( lh ) ;
parts . push ( head , name , data ) ;
central . push ( { name , crc , size : data . length , offset } ) ;
offset += head . length + name . length + data . length ;
}
const cdParts = [ ] ;
let cdSize = 0 ;
for ( const c of central ) {
const ch = [ ] . concat ( u32 ( 0x02014b50 ) , u16 ( 20 ) , u16 ( 20 ) , u16 ( 0x0800 ) , u16 ( 0 ) , u16 ( dosTime ) , u16 ( dosDate ) ,
u32 ( c . crc ) , u32 ( c . size ) , u32 ( c . size ) , u16 ( c . name . length ) , u16 ( 0 ) , u16 ( 0 ) , u16 ( 0 ) , u16 ( 0 ) , u32 ( 0 ) , u32 ( c . offset ) ) ;
const head = new Uint8Array ( ch ) ;
cdParts . push ( head , c . name ) ;
cdSize += head . length + c . name . length ;
}
const end = new Uint8Array ( [ ] . concat ( u32 ( 0x06054b50 ) , u16 ( 0 ) , u16 ( 0 ) , u16 ( central . length ) , u16 ( central . length ) ,
u32 ( cdSize ) , u32 ( offset ) , u16 ( 0 ) ) ) ;
const all = [ ... parts , ... cdParts , end ] ;
const total = all . reduce ( ( n , p ) => n + p . length , 0 ) ;
const out = new Uint8Array ( total ) ;
let p = 0 ; for ( const a of all ) { out . set ( a , p ) ; p += a . length ; }
return out ;
}
/* =========================================================================
导出
========================================================================= */
async function exportZip ( ) {
const slug = S . slug . trim ( ) ;
if ( ! /^[a-z0-9][a-z0-9-]{0,31}$/ . test ( slug ) ) { out ( 'slug 不合法:只允许小写字母/数字/连字符,且不能以连字符开头。' , 'bad' ) ; return ; }
const files = [ ] ;
const imgDir = ` static/images/themes/ ${ slug } / ` ;
const css = buildCSS ( )
. replaceAll ( 'IMAGES/' , imgDir . replace ( 'static/images/themes/' , '/static/images/themes/' ) ) ;
files . push ( { name : ` static/css/themes/ ${ slug } .css ` , data : css } ) ;
const add = ( asset , name ) => { if ( asset ) files . push ( { name : imgDir + name , data : asset . bytes } ) ; } ;
add ( S . scene , 'scene-desktop.webp' ) ;
add ( S . sceneMobile , 'scene-mobile.webp' ) ;
add ( S . char , 'character.webp' ) ;
add ( S . prop , 'prop.webp' ) ;
files . push ( { name : ` INSTALL- ${ slug } .txt ` , data : installText ( slug ) } ) ;
const bin = zipStore ( files ) ;
const url = URL . createObjectURL ( new Blob ( [ bin ] , { type : 'application/zip' } ) ) ;
const a = document . createElement ( 'a' ) ;
a . href = url ; a . download = ` theme- ${ slug } .zip ` ; a . click ( ) ;
setTimeout ( ( ) => URL . revokeObjectURL ( url ) , 4000 ) ;
out ( ` 已导出 theme- ${ slug } .zip · ${ files . length } 个文件 · ${ ( bin . length / 1024 ) . toFixed ( 0 ) } KB ` , 'ok' ) ;
}
function installText ( slug ) {
return ` 主题 ${ S . name } ( ${ slug } )安装说明
==================================================
1) 解压到你项目的根目录(与 go.mod 同级)。
会得到:
static/css/themes/ ${ slug } .css
static/images/themes/ ${ slug } /
${ S . scene ? 'scene-desktop.webp\n ' : '' } ${ S . sceneMobile ? 'scene-mobile.webp\n ' : '' } ${ S . char ? 'character.webp\n ' : '' } ${ S . prop ? 'prop.webp\n ' : '' }
2) 登记主题(conf/app.ini):
[theme]
default = default
available = default, ${ slug }
⚠️ 没登记的话设置页看不到这个主题——现象和"部署没生效"一模一样。
3) 改静态版本号,让老访客不吃缓存:
internal/conf/static.go → StaticVersion(日期串 +1)
4) 重新编译并重启(静态资源是编译期 embed,只重启不重编不会变):
go build . && ./TamaBox web
5) 箱主在「账号设置 → 主题外观」里选择 ${ S . name } 。
说明
--------------------------------------------------
· 图片不使用 StaticVersion 缓存键(max-age 1 天)。以后换图要给 URL 带上 ?v=。
· 深色模式不加载图片,只走深色令牌,所以深色下不会出现白天场景。
· 若显示尺寸与素材比例不符,改 css 里的 --tb-deco-size( auto <高度>px)。
` ;
}
function out ( msg , cls ) { const el = $ ( 'out' ) ; el . innerHTML = msg ; el . className = 'out ' + ( cls || '' ) ; }
/* =========================================================================
上传
========================================================================= */
let pendingSlot = null ;
document . querySelectorAll ( '.drop' ) . forEach ( el => {
el . addEventListener ( 'click' , ( ) => { pendingSlot = el . id . replace ( 'drop-' , '' ) ; $ ( 'file' ) . click ( ) ; } ) ;
} ) ;
$ ( 'file' ) . addEventListener ( 'change' , async e => {
const f = e . target . files [ 0 ] ; if ( ! f ) return ;
await handleFile ( pendingSlot , f ) ;
e . target . value = '' ;
} ) ;
async function handleFile ( slot , file ) {
out ( '处理图片…' ) ;
if ( slot === 'scene' ) S . scene = await webpFrom ( file , { mode : 'cover' , w : 1920 , h : 1080 , q : 0.80 } ) ;
if ( slot === 'scene-m' ) S . sceneMobile = await webpFrom ( file , { mode : 'cover' , w : 720 , h : 1003 , q : 0.80 } ) ;
if ( slot === 'char' ) S . char = await webpFrom ( file , { mode : 'contain' , w : 1200 , h : Math . round ( S . pos . charH * 1.5 ) , q : 0.88 } ) ;
if ( slot === 'prop' ) S . prop = await webpFrom ( file , { mode : 'contain' , w : 1600 , h : Math . round ( S . pos . propH * 1.5 ) , q : 0.88 , shadow : 10 } ) ;
refreshDrops ( ) ; render ( ) ;
out ( '' ) ;
}
function refreshDrops ( ) {
const map = { 'drop-scene' : S . scene , 'drop-scene-m' : S . sceneMobile , 'drop-char' : S . char , 'drop-prop' : S . prop } ;
const label = { 'drop-scene' : '背景图(桌面)' , 'drop-scene-m' : '背景图(移动端)' , 'drop-char' : '人物' , 'drop-prop' : '道具' } ;
for ( const [ id , val ] of Object . entries ( map ) ) {
const el = $ ( id ) ;
el . classList . toggle ( 'filled' , ! ! val ) ;
el . textContent = val ? ` ${ label [ id ] } ✓ ${ val . w * 0 + Math . round ( val . kb ) } KB ` : ( id === 'drop-scene' ? '背景图(16:9,会被压成 1920× 1080) ' :
id === 'drop-scene-m' ? '背景图 · 移动端(可选,竖版)' : id === 'drop-char' ? '人物(透明底 PNG/WebP) ' : '道具 / 摆件(可选,透明底)' ) ;
}
const t = $ ( 'thumbs' ) ; t . innerHTML = '' ;
for ( const [ k , v ] of Object . entries ( { scene : S . scene , mobile : S . sceneMobile , char : S . char , prop : S . prop } ) ) {
if ( ! v ) continue ;
const d = document . createElement ( 'div' ) ; d . className = 'thumb' ;
d . innerHTML = ` <img src=" ${ v . url } " alt=" ${ k } "> ` ;
t . appendChild ( d ) ;
}
}
/* =========================================================================
控件绑定
========================================================================= */
function bindRange ( id , get , set , fmt ) {
const el = $ ( id ) , o = $ ( 'o-' + id . replace ( /^c-/ , '' ) ) ;
el . addEventListener ( 'input' , ( ) => { set ( parseFloat ( el . value ) ) ; if ( o ) o . value = fmt ? fmt ( el . value ) : el . value ; render ( ) ; } ) ;
}
$ ( 'slug' ) . addEventListener ( 'input' , e => { S . slug = e . target . value ; } ) ;
$ ( 'name' ) . addEventListener ( 'input' , e => { S . name = e . target . value ; } ) ;
$ ( 'charh' ) . addEventListener ( 'input' , e => { S . pos . charH = + e . target . value ; $ ( 'o-charh' ) . value = e . target . value ; render ( ) ; } ) ;
$ ( 'charb' ) . addEventListener ( 'input' , e => { S . pos . charB = + e . target . value ; $ ( 'o-charb' ) . value = e . target . value ; render ( ) ; } ) ;
$ ( 'chardx' ) . addEventListener ( 'input' , e => { S . pos . charDx = + e . target . value ; $ ( 'o-chardx' ) . value = e . target . value ; render ( ) ; } ) ;
$ ( 'charsh' ) . addEventListener ( 'input' , e => { S . pos . charShadow = + e . target . value ; render ( ) ; } ) ;
$ ( 'prop-on' ) . addEventListener ( 'change' , e => { S . pos . propOn = e . target . checked ; render ( ) ; } ) ;
$ ( 'proph' ) . addEventListener ( 'input' , e => { S . pos . propH = + e . target . value ; $ ( 'o-proph' ) . value = e . target . value ; render ( ) ; } ) ;
$ ( 'propb' ) . addEventListener ( 'input' , e => { S . pos . propB = + e . target . value ; $ ( 'o-propb' ) . value = e . target . value ; render ( ) ; } ) ;
$ ( 'propdx' ) . addEventListener ( 'input' , e => { S . pos . propDx = + e . target . value ; $ ( 'o-propdx' ) . value = e . target . value ; render ( ) ; } ) ;
$ ( 'beam' ) . addEventListener ( 'input' , e => { S . air . beam = + e . target . value ; $ ( 'o-beam' ) . value = ( + e . target . value ) . toFixed ( 2 ) ; render ( ) ; } ) ;
$ ( 'pool' ) . addEventListener ( 'input' , e => { S . air . pool = + e . target . value ; $ ( 'o-pool' ) . value = ( + e . target . value ) . toFixed ( 2 ) ; render ( ) ; } ) ;
$ ( 'vig' ) . addEventListener ( 'input' , e => { S . air . vig = + e . target . value ; $ ( 'o-vig' ) . value = ( + e . target . value ) . toFixed ( 2 ) ; render ( ) ; } ) ;
[ [ 'c-bg' , 'bg' ] , [ 'c-primary' , 'primary' ] , [ 'c-surface' , 'surface' ] , [ 'c-text' , 'text' ] , [ 'c-dbg' , 'dbg' ] , [ 'c-dprimary' , 'dprimary' ] ]
. forEach ( ( [ id , key ] ) => $ ( id ) . addEventListener ( 'input' , e => { S . colors [ key ] = e . target . value ; render ( ) ; } ) ) ;
$ ( 'auto-dark' ) . addEventListener ( 'change' , e => {
S . colors . autoDark = e . target . checked ;
$ ( 'dark-row' ) . style . opacity = e . target . checked ? . 45 : 1 ;
$ ( 'dark-row' ) . style . pointerEvents = e . target . checked ? 'none' : 'auto' ;
render ( ) ;
} ) ;
document . querySelectorAll ( '#seg-w button' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => {
document . querySelectorAll ( '#seg-w button' ) . forEach ( x => x . classList . remove ( 'on' ) ) ;
b . classList . add ( 'on' ) ; S . viewport = + b . dataset . w ; render ( ) ;
} ) ) ;
document . querySelectorAll ( '#seg-theme button' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => {
document . querySelectorAll ( '#seg-theme button' ) . forEach ( x => x . classList . remove ( 'on' ) ) ;
b . classList . add ( 'on' ) ; S . dark = b . dataset . theme === 'dark' ; render ( ) ;
} ) ) ;
$ ( 'btn-export' ) . addEventListener ( 'click' , exportZip ) ;
window . addEventListener ( 'resize' , fitStage ) ;
/* =========================================================================
示例:没有素材也能先看效果(用 canvas 画渐变背景 + 一个剪影)
========================================================================= */
function loadDemo ( ) {
try {
const bg = document . createElement ( 'canvas' ) ; bg . width = 1920 ; bg . height = 1080 ;
const g = bg . getContext ( '2d' ) ;
const grd = g . createLinearGradient ( 0 , 0 , 1920 , 1080 ) ;
grd . addColorStop ( 0 , '#dfe7f1' ) ; grd . addColorStop ( . 55 , '#eef3f9' ) ; grd . addColorStop ( 1 , '#f7f9fb' ) ;
g . fillStyle = grd ; g . fillRect ( 0 , 0 , 1920 , 1080 ) ;
g . fillStyle = 'rgba(180,196,214,.35)' ;
for ( let i = 0 ; i < 7 ; i ++ ) { g . fillRect ( 120 + i * 250 , 470 + i * 12 , 190 , 18 ) ; }
S . scene = canvasAsset ( bg , . 8 ) ;
const ch = document . createElement ( 'canvas' ) ; ch . width = 270 ; ch . height = 920 ;
const c = ch . getContext ( '2d' ) ;
c . fillStyle = '#c4c3c6' ; c . beginPath ( ) ; c . arc ( 135 , 150 , 86 , 0 , 7 ) ; c . fill ( ) ; // 头
c . fillStyle = '#8abbc6' ; c . beginPath ( ) ; c . ellipse ( 135 , 92 , 72 , 24 , 0 , 0 , 7 ) ; c . fill ( ) ; // 光环
c . fillStyle = '#565e72' ; c . fillRect ( 85 , 220 , 100 , 260 ) ; // 上身
c . fillStyle = '#9aa3ad' ; c . fillRect ( 85 , 470 , 100 , 150 ) ; // 裙
c . fillStyle = '#ededed' ; c . fillRect ( 95 , 610 , 34 , 180 ) ; c . fillRect ( 141 , 610 , 34 , 180 ) ; // 腿
c . fillStyle = '#3f3f3f' ; c . fillRect ( 88 , 780 , 50 , 30 ) ; c . fillRect ( 136 , 780 , 50 , 30 ) ; // 鞋
S . char = canvasAsset ( ch , . 9 ) ;
refreshDrops ( ) ; render ( ) ; out ( '已载入示例素材(可在「图片」区换成自己的)' , 'ok' ) ;
} catch ( err ) { out ( '示例加载失败:' + err . message + '\n' + err . stack , 'bad' ) ;
const pre = document . createElement ( 'pre' ) ; pre . id = 'selftest-err' ; pre . textContent = String ( err . stack || err ) ;
document . body . appendChild ( pre ) ; }
}
$ ( 'btn-demo' ) . addEventListener ( 'click' , loadDemo ) ;
/* =========================================================================
自检:?selftest=1 时把 CSS 与 zip 的字节吐到 DOM,供无头浏览器校验
========================================================================= */
( function selftest ( ) {
if ( ! location . search . includes ( 'selftest' ) ) return ;
( ( ) => {
try { loadDemo ( ) ; document . title = 'demo:' + ( S . char ? S . char . w + 'x' + S . char . h : 'none' ) ; }
catch ( e ) { document . title = 'demo-fail:' + e . message ; }
const css = buildCSS ( ) ;
const bin = zipStore ( [
{ name : 'a.txt' , data : 'hello' } ,
{ name : ` static/css/themes/ ${ S . slug } .css ` , data : css }
] ) ;
let b64 = '' ; const chunk = 0x8000 ;
for ( let i = 0 ; i < bin . length ; i += chunk ) b64 += String . fromCharCode . apply ( null , bin . subarray ( i , i + chunk ) ) ;
const pre = document . createElement ( 'pre' ) ;
pre . id = 'selftest' ;
pre . textContent = JSON . stringify ( {
css ,
zipB64 : btoa ( b64 ) ,
zipLen : bin . length ,
cssLen : css . length
} ) ;
document . body . appendChild ( pre ) ;
} ) ( ) ;
} ) ( ) ;
refreshDrops ( ) ;
render ( ) ;
if ( location . search . includes ( 'demo' ) ) loadDemo ( ) ;
if ( location . search . includes ( 'dark' ) ) {
S . dark = true ;
document . querySelectorAll ( '#seg-theme button' ) . forEach ( b => b . classList . toggle ( 'on' , b . dataset . theme === 'dark' ) ) ;
render ( ) ;
}
< / script >
< / body >
< / html >