update charator idel char-1777017632279
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"gauntlet-077c293962b8fe07.png": {
|
||||
"label": "obstacles-2",
|
||||
"updatedAt": 1777530870272
|
||||
},
|
||||
"gauntlet-67a585790f67fe67.png": {
|
||||
"label": "obstacles-3",
|
||||
"updatedAt": 1777530881182
|
||||
},
|
||||
"gauntlet-c4d4482e2a60eac9.png": {
|
||||
"label": "obstacles-4",
|
||||
"updatedAt": 1777530884803
|
||||
},
|
||||
"gauntlet-6aa244ab734b12e4.png": {
|
||||
"label": "obstacles-5",
|
||||
"updatedAt": 1777530889730
|
||||
},
|
||||
"gauntlet-2a75a958cd5e5e8a.png": {
|
||||
"label": "obstacles-1",
|
||||
"updatedAt": 1777530896068
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -60,6 +60,55 @@
|
||||
.char-edit-btn:hover { background: #7aa2f7; color: #1a1b26; }
|
||||
.char-delete-btn { background: #414868; color: #a9b1d6; }
|
||||
.char-delete-btn:hover { background: #f7768e; color: #1a1b26; }
|
||||
/* idle ฝังในแบบเลเยอร์ — แยกโซนสีม่วง */
|
||||
.char-layer-global-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin: 0.75rem 0 1rem;
|
||||
padding: 0.35rem;
|
||||
background: rgba(26, 27, 38, 0.65);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(122, 162, 247, 0.25);
|
||||
}
|
||||
.char-layer-global-tabs .char-mode-tab {
|
||||
flex: 1 1 auto;
|
||||
min-width: 120px;
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.88rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #a9b1d6;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.char-layer-global-tabs .char-mode-tab:hover {
|
||||
color: #c0caf5;
|
||||
background: rgba(122, 162, 247, 0.12);
|
||||
}
|
||||
.char-layer-global-tabs .char-mode-tab.is-active {
|
||||
background: #414868;
|
||||
color: #c0caf5;
|
||||
border-color: rgba(187, 154, 247, 0.45);
|
||||
box-shadow: 0 0 0 1px rgba(122, 162, 247, 0.2);
|
||||
}
|
||||
#char-upload-advanced .char-layer-panel-stack { min-height: 0; }
|
||||
#char-upload-advanced[data-adv-layer-mode="move"] .char-layer-panel--idle { display: none !important; }
|
||||
#char-upload-advanced[data-adv-layer-mode="idle"] .char-layer-panel--move { display: none !important; }
|
||||
#char-upload-advanced .char-layer-panel--move,
|
||||
#char-upload-advanced .char-layer-panel--idle {
|
||||
text-align: left;
|
||||
}
|
||||
#char-upload-advanced .char-panel-subtle {
|
||||
margin: 0 0 0.4rem 0;
|
||||
font-size: 0.78rem;
|
||||
color: #bb9af7;
|
||||
line-height: 1.35;
|
||||
}
|
||||
#char-upload-advanced .preview-idle-inline {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
/* ฝังใน Admin (iframe): ลดหัวข้อ/ลิงก์นำทาง */
|
||||
html.character-embed-admin .character-page-links,
|
||||
html.character-embed-admin body > .container > h1:first-of-type,
|
||||
@@ -93,7 +142,7 @@
|
||||
|
||||
<section class="char-upload char-upload-simple">
|
||||
<h2>อัปโหลดตัวละคร (บน · ล่าง · ซ้าย · ขวา)</h2>
|
||||
<p class="legend">เลือกรูปทั้ง 4 ทิศ แล้วใส่ชื่อ (ไม่บังคับ) กดอัปโหลด</p>
|
||||
<p class="legend">เลือกรูปทั้ง 4 ทิศ แล้วใส่ชื่อ (ไม่บังคับ) กดอัปโหลด · รูป <strong>ยืนเฉย (idle)</strong> แยกเลเยอร์ต่อทิศอยู่ในตาราง <strong>แบบเลเยอร์</strong> ด้านล่าง</p>
|
||||
<div class="char-upload-grid">
|
||||
<div class="char-upload-cell">
|
||||
<label>บน (up) <small>เลือกได้หลายเฟรม</small></label>
|
||||
@@ -123,85 +172,167 @@
|
||||
<p id="upload-status" style="margin-top:0.5rem;font-size:0.9rem;color:#a9b1d6;"></p>
|
||||
</section>
|
||||
|
||||
<section class="char-upload" id="char-upload-advanced">
|
||||
<section class="char-upload" id="char-upload-advanced" data-adv-layer-mode="move">
|
||||
<h2>อัปโหลดตัวละครแบบเลเยอร์ (Shadow / Body / Hair / Head / Face)</h2>
|
||||
<p class="legend">สำหรับผู้สร้างสกิน: แยกไฟล์ตามเลเยอร์ + ทิศ ระบบจะรวมเป็นเฟรมให้เองก่อนอัปโหลด (ไม่ใส่เงา = ใช้ค่าเริ่มต้น)</p>
|
||||
<p class="legend">สำหรับผู้สร้างสกิน: แยกไฟล์ตามเลเยอร์ + ทิศ · ใช้เมนูด้านล่างสลับระหว่าง <strong>เดิน</strong> กับ <strong>ยืนเฉย (idle)</strong> — ถ้าไม่ใส่ idle เกมจะใช้เฟรมเดินเมื่อยืนนิ่ง</p>
|
||||
|
||||
<div class="char-layer-global-tabs" id="char-layer-global-tabs" role="tablist" aria-label="โหมดเลเยอร์เดินและยืนเฉย">
|
||||
<button type="button" class="char-mode-tab is-active" role="tab" aria-selected="true" data-adv-mode="move">เดิน (Move)</button>
|
||||
<button type="button" class="char-mode-tab" role="tab" aria-selected="false" data-adv-mode="idle">ยืนเฉย (Idle)</button>
|
||||
</div>
|
||||
|
||||
<div class="char-upload-grid">
|
||||
<div class="char-upload-cell">
|
||||
<label>บน (up)</label>
|
||||
<div>
|
||||
<strong>Shadow</strong>
|
||||
<label>Shadow <input type="file" id="layer-up-shadow" accept="image/*" multiple></label>
|
||||
<strong>Body</strong>
|
||||
<label>Color <input type="file" id="layer-up-bodyColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-up-bodyStroke" accept="image/*" multiple></label>
|
||||
<strong>Hair</strong>
|
||||
<label>Color <input type="file" id="layer-up-hairColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-up-hairStroke" accept="image/*" multiple></label>
|
||||
<strong>Head</strong>
|
||||
<label>Color <input type="file" id="layer-up-headColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-up-headStroke" accept="image/*" multiple></label>
|
||||
<strong>Face</strong>
|
||||
<label>Face <input type="file" id="layer-up-face" accept="image/*" multiple></label>
|
||||
<div class="char-layer-panel-stack">
|
||||
<div class="char-layer-panel char-layer-panel--move">
|
||||
<strong>Shadow</strong>
|
||||
<label>Shadow <input type="file" id="layer-up-shadow" accept="image/*" multiple></label>
|
||||
<strong>Body</strong>
|
||||
<label>Color <input type="file" id="layer-up-bodyColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-up-bodyStroke" accept="image/*" multiple></label>
|
||||
<strong>Hair</strong>
|
||||
<label>Color <input type="file" id="layer-up-hairColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-up-hairStroke" accept="image/*" multiple></label>
|
||||
<strong>Head</strong>
|
||||
<label>Color <input type="file" id="layer-up-headColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-up-headStroke" accept="image/*" multiple></label>
|
||||
<strong>Face</strong>
|
||||
<label>Face <input type="file" id="layer-up-face" accept="image/*" multiple></label>
|
||||
<img id="preview-up-layer" class="preview" alt="">
|
||||
</div>
|
||||
<div class="char-layer-panel char-layer-panel--idle">
|
||||
<p class="char-panel-subtle">เลเยอร์ยืนเฉย (idle) ทิศบน — โครงเดียวกับเดิน</p>
|
||||
<strong>Shadow</strong>
|
||||
<label>Shadow <input type="file" id="layer-idle-up-shadow" accept="image/*" multiple></label>
|
||||
<strong>Body</strong>
|
||||
<label>Color <input type="file" id="layer-idle-up-bodyColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-idle-up-bodyStroke" accept="image/*" multiple></label>
|
||||
<strong>Hair</strong>
|
||||
<label>Color <input type="file" id="layer-idle-up-hairColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-idle-up-hairStroke" accept="image/*" multiple></label>
|
||||
<strong>Head</strong>
|
||||
<label>Color <input type="file" id="layer-idle-up-headColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-idle-up-headStroke" accept="image/*" multiple></label>
|
||||
<strong>Face</strong>
|
||||
<label>Face <input type="file" id="layer-idle-up-face" accept="image/*" multiple></label>
|
||||
<img id="preview-idle-layer-up" class="preview preview-idle-inline" alt="">
|
||||
</div>
|
||||
</div>
|
||||
<img id="preview-up-layer" class="preview" alt="">
|
||||
</div>
|
||||
<div class="char-upload-cell">
|
||||
<label>ล่าง (down)</label>
|
||||
<div>
|
||||
<strong>Shadow</strong>
|
||||
<label>Shadow <input type="file" id="layer-down-shadow" accept="image/*" multiple></label>
|
||||
<strong>Body</strong>
|
||||
<label>Color <input type="file" id="layer-down-bodyColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-down-bodyStroke" accept="image/*" multiple></label>
|
||||
<strong>Hair</strong>
|
||||
<label>Color <input type="file" id="layer-down-hairColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-down-hairStroke" accept="image/*" multiple></label>
|
||||
<strong>Head</strong>
|
||||
<label>Color <input type="file" id="layer-down-headColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-down-headStroke" accept="image/*" multiple></label>
|
||||
<strong>Face</strong>
|
||||
<label>Face <input type="file" id="layer-down-face" accept="image/*" multiple></label>
|
||||
<div class="char-layer-panel-stack">
|
||||
<div class="char-layer-panel char-layer-panel--move">
|
||||
<strong>Shadow</strong>
|
||||
<label>Shadow <input type="file" id="layer-down-shadow" accept="image/*" multiple></label>
|
||||
<strong>Body</strong>
|
||||
<label>Color <input type="file" id="layer-down-bodyColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-down-bodyStroke" accept="image/*" multiple></label>
|
||||
<strong>Hair</strong>
|
||||
<label>Color <input type="file" id="layer-down-hairColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-down-hairStroke" accept="image/*" multiple></label>
|
||||
<strong>Head</strong>
|
||||
<label>Color <input type="file" id="layer-down-headColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-down-headStroke" accept="image/*" multiple></label>
|
||||
<strong>Face</strong>
|
||||
<label>Face <input type="file" id="layer-down-face" accept="image/*" multiple></label>
|
||||
<img id="preview-down-layer" class="preview" alt="">
|
||||
</div>
|
||||
<div class="char-layer-panel char-layer-panel--idle">
|
||||
<p class="char-panel-subtle">เลเยอร์ยืนเฉย (idle) ทิศล่าง</p>
|
||||
<strong>Shadow</strong>
|
||||
<label>Shadow <input type="file" id="layer-idle-down-shadow" accept="image/*" multiple></label>
|
||||
<strong>Body</strong>
|
||||
<label>Color <input type="file" id="layer-idle-down-bodyColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-idle-down-bodyStroke" accept="image/*" multiple></label>
|
||||
<strong>Hair</strong>
|
||||
<label>Color <input type="file" id="layer-idle-down-hairColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-idle-down-hairStroke" accept="image/*" multiple></label>
|
||||
<strong>Head</strong>
|
||||
<label>Color <input type="file" id="layer-idle-down-headColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-idle-down-headStroke" accept="image/*" multiple></label>
|
||||
<strong>Face</strong>
|
||||
<label>Face <input type="file" id="layer-idle-down-face" accept="image/*" multiple></label>
|
||||
<img id="preview-idle-layer-down" class="preview preview-idle-inline" alt="">
|
||||
</div>
|
||||
</div>
|
||||
<img id="preview-down-layer" class="preview" alt="">
|
||||
</div>
|
||||
<div class="char-upload-cell">
|
||||
<label>ซ้าย (left)</label>
|
||||
<div>
|
||||
<strong>Shadow</strong>
|
||||
<label>Shadow <input type="file" id="layer-left-shadow" accept="image/*" multiple></label>
|
||||
<strong>Body</strong>
|
||||
<label>Color <input type="file" id="layer-left-bodyColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-left-bodyStroke" accept="image/*" multiple></label>
|
||||
<strong>Hair</strong>
|
||||
<label>Color <input type="file" id="layer-left-hairColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-left-hairStroke" accept="image/*" multiple></label>
|
||||
<strong>Head</strong>
|
||||
<label>Color <input type="file" id="layer-left-headColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-left-headStroke" accept="image/*" multiple></label>
|
||||
<strong>Face</strong>
|
||||
<label>Face <input type="file" id="layer-left-face" accept="image/*" multiple></label>
|
||||
<div class="char-layer-panel-stack">
|
||||
<div class="char-layer-panel char-layer-panel--move">
|
||||
<strong>Shadow</strong>
|
||||
<label>Shadow <input type="file" id="layer-left-shadow" accept="image/*" multiple></label>
|
||||
<strong>Body</strong>
|
||||
<label>Color <input type="file" id="layer-left-bodyColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-left-bodyStroke" accept="image/*" multiple></label>
|
||||
<strong>Hair</strong>
|
||||
<label>Color <input type="file" id="layer-left-hairColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-left-hairStroke" accept="image/*" multiple></label>
|
||||
<strong>Head</strong>
|
||||
<label>Color <input type="file" id="layer-left-headColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-left-headStroke" accept="image/*" multiple></label>
|
||||
<strong>Face</strong>
|
||||
<label>Face <input type="file" id="layer-left-face" accept="image/*" multiple></label>
|
||||
<img id="preview-left-layer" class="preview" alt="">
|
||||
</div>
|
||||
<div class="char-layer-panel char-layer-panel--idle">
|
||||
<p class="char-panel-subtle">เลเยอร์ยืนเฉย (idle) ทิศซ้าย</p>
|
||||
<strong>Shadow</strong>
|
||||
<label>Shadow <input type="file" id="layer-idle-left-shadow" accept="image/*" multiple></label>
|
||||
<strong>Body</strong>
|
||||
<label>Color <input type="file" id="layer-idle-left-bodyColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-idle-left-bodyStroke" accept="image/*" multiple></label>
|
||||
<strong>Hair</strong>
|
||||
<label>Color <input type="file" id="layer-idle-left-hairColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-idle-left-hairStroke" accept="image/*" multiple></label>
|
||||
<strong>Head</strong>
|
||||
<label>Color <input type="file" id="layer-idle-left-headColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-idle-left-headStroke" accept="image/*" multiple></label>
|
||||
<strong>Face</strong>
|
||||
<label>Face <input type="file" id="layer-idle-left-face" accept="image/*" multiple></label>
|
||||
<img id="preview-idle-layer-left" class="preview preview-idle-inline" alt="">
|
||||
</div>
|
||||
</div>
|
||||
<img id="preview-left-layer" class="preview" alt="">
|
||||
</div>
|
||||
<div class="char-upload-cell">
|
||||
<label>ขวา (right)</label>
|
||||
<div>
|
||||
<strong>Shadow</strong>
|
||||
<label>Shadow <input type="file" id="layer-right-shadow" accept="image/*" multiple></label>
|
||||
<strong>Body</strong>
|
||||
<label>Color <input type="file" id="layer-right-bodyColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-right-bodyStroke" accept="image/*" multiple></label>
|
||||
<strong>Hair</strong>
|
||||
<label>Color <input type="file" id="layer-right-hairColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-right-hairStroke" accept="image/*" multiple></label>
|
||||
<strong>Head</strong>
|
||||
<label>Color <input type="file" id="layer-right-headColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-right-headStroke" accept="image/*" multiple></label>
|
||||
<strong>Face</strong>
|
||||
<label>Face <input type="file" id="layer-right-face" accept="image/*" multiple></label>
|
||||
<div class="char-layer-panel-stack">
|
||||
<div class="char-layer-panel char-layer-panel--move">
|
||||
<strong>Shadow</strong>
|
||||
<label>Shadow <input type="file" id="layer-right-shadow" accept="image/*" multiple></label>
|
||||
<strong>Body</strong>
|
||||
<label>Color <input type="file" id="layer-right-bodyColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-right-bodyStroke" accept="image/*" multiple></label>
|
||||
<strong>Hair</strong>
|
||||
<label>Color <input type="file" id="layer-right-hairColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-right-hairStroke" accept="image/*" multiple></label>
|
||||
<strong>Head</strong>
|
||||
<label>Color <input type="file" id="layer-right-headColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-right-headStroke" accept="image/*" multiple></label>
|
||||
<strong>Face</strong>
|
||||
<label>Face <input type="file" id="layer-right-face" accept="image/*" multiple></label>
|
||||
<img id="preview-right-layer" class="preview" alt="">
|
||||
</div>
|
||||
<div class="char-layer-panel char-layer-panel--idle">
|
||||
<p class="char-panel-subtle">เลเยอร์ยืนเฉย (idle) ทิศขวา</p>
|
||||
<strong>Shadow</strong>
|
||||
<label>Shadow <input type="file" id="layer-idle-right-shadow" accept="image/*" multiple></label>
|
||||
<strong>Body</strong>
|
||||
<label>Color <input type="file" id="layer-idle-right-bodyColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-idle-right-bodyStroke" accept="image/*" multiple></label>
|
||||
<strong>Hair</strong>
|
||||
<label>Color <input type="file" id="layer-idle-right-hairColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-idle-right-hairStroke" accept="image/*" multiple></label>
|
||||
<strong>Head</strong>
|
||||
<label>Color <input type="file" id="layer-idle-right-headColor" accept="image/*" multiple></label>
|
||||
<label>Stroke <input type="file" id="layer-idle-right-headStroke" accept="image/*" multiple></label>
|
||||
<strong>Face</strong>
|
||||
<label>Face <input type="file" id="layer-idle-right-face" accept="image/*" multiple></label>
|
||||
<img id="preview-idle-layer-right" class="preview preview-idle-inline" alt="">
|
||||
</div>
|
||||
</div>
|
||||
<img id="preview-right-layer" class="preview" alt="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="char-upload-actions">
|
||||
@@ -217,7 +348,7 @@
|
||||
<p id="char-hint" style="display:none; color: #a9b1d6;">ยังไม่มีตัวละคร — อัปโหลดด้านบน</p>
|
||||
</div>
|
||||
<script src="js/version.js?v=0.0112"></script>
|
||||
<script src="js/character.js?v=0.013"></script>
|
||||
<script src="js/character.js?v=0.020"></script>
|
||||
<div class="version-tag">v —</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,11 +11,19 @@
|
||||
const DEFAULT_SHADOW_URL = BASE + '/img/default-shadow-';
|
||||
const fileInputs = {};
|
||||
const previews = {};
|
||||
const idleLayerFileInputs = {};
|
||||
const idleLayerPreviews = {};
|
||||
const layerFileInputs = {};
|
||||
const layerPreviews = {};
|
||||
dirs.forEach(d => {
|
||||
fileInputs[d] = document.getElementById('file-' + d);
|
||||
previews[d] = document.getElementById('preview-' + d);
|
||||
idleLayerFileInputs[d] = {};
|
||||
layers.forEach(layer => {
|
||||
const el = document.getElementById('layer-idle-' + d + '-' + layer);
|
||||
if (el) idleLayerFileInputs[d][layer] = el;
|
||||
});
|
||||
idleLayerPreviews[d] = document.getElementById('preview-idle-layer-' + d);
|
||||
layerFileInputs[d] = {};
|
||||
layers.forEach(layer => {
|
||||
const inputId = 'layer-' + d + '-' + layer;
|
||||
@@ -27,7 +35,6 @@
|
||||
|
||||
/** จาก GET /api/characters/:id/layer-manifest — ใช้ตอนกดแก้ไขให้ preview / รวมเลเยอร์ก่อนอัปโหลดซ้ำ */
|
||||
let editLayerManifest = null;
|
||||
const LAYER_EDIT_FLOW_DONE = {};
|
||||
|
||||
function clearLayerThumbStrips() {
|
||||
document.querySelectorAll('.char-layer-seq-strip').forEach((el) => el.remove());
|
||||
@@ -39,80 +46,17 @@
|
||||
return e.frames[frameIndex];
|
||||
}
|
||||
|
||||
function manifestIdleFrameAt(dir, frameIndex) {
|
||||
const e = editLayerManifest && editLayerManifest.byDirIdle && editLayerManifest.byDirIdle[dir];
|
||||
if (!e || !e.frames || !e.frames[frameIndex]) return null;
|
||||
return e.frames[frameIndex];
|
||||
}
|
||||
|
||||
function characterLayerFileUrl(fname) {
|
||||
if (!fname || typeof fname !== 'string') return '';
|
||||
return BASE + '/img/characters/' + encodeURIComponent(fname);
|
||||
}
|
||||
|
||||
/** โหลด manifest ไม่ได้ (Node เก่า / proxy) — โปรบ URL รูปเลเยอร์จริงบนดิสก์ */
|
||||
function probeCharacterImageUrl(url) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const t = setTimeout(() => {
|
||||
try { img.src = ''; } catch (e) { /* ignore */ }
|
||||
resolve(false);
|
||||
}, 2200);
|
||||
img.onload = () => {
|
||||
clearTimeout(t);
|
||||
try { img.src = ''; } catch (e) { /* ignore */ }
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
clearTimeout(t);
|
||||
resolve(false);
|
||||
};
|
||||
img.src = url + (url.indexOf('?') >= 0 ? '&' : '?') + '_probe=' + Date.now();
|
||||
});
|
||||
}
|
||||
|
||||
async function buildLayerManifestByProbing(charId) {
|
||||
const byDir = {};
|
||||
const enc = (s) => encodeURIComponent(s);
|
||||
for (let di = 0; di < dirs.length; di++) {
|
||||
const d = dirs[di];
|
||||
const r0 = await Promise.all(layers.map((layer) => {
|
||||
const url = BASE + '/img/characters/' + enc(charId) + '_' + d + '_0_layer_' + layer + '.png';
|
||||
return probeCharacterImageUrl(url).then((ok) => (ok ? layer : null));
|
||||
}));
|
||||
const multi = r0.some(Boolean);
|
||||
if (multi) {
|
||||
const frames = [];
|
||||
for (let fi = 0; fi < 64; fi++) {
|
||||
const results = await Promise.all(layers.map((layer) => {
|
||||
const fname = charId + '_' + d + '_' + fi + '_layer_' + layer + '.png';
|
||||
const url = BASE + '/img/characters/' + enc(fname);
|
||||
return probeCharacterImageUrl(url).then((ok) => (ok ? { layer, fname } : null));
|
||||
}));
|
||||
const frameObj = {};
|
||||
results.forEach((x) => { if (x) frameObj[x.layer] = x.fname; });
|
||||
if (Object.keys(frameObj).length === 0) break;
|
||||
frames.push(frameObj);
|
||||
}
|
||||
if (frames.length) byDir[d] = { multi: true, frames };
|
||||
} else {
|
||||
const results = await Promise.all(layers.map((layer) => {
|
||||
const fname = charId + '_' + d + '_layer_' + layer + '.png';
|
||||
const url = BASE + '/img/characters/' + enc(fname);
|
||||
return probeCharacterImageUrl(url).then((ok) => (ok ? { layer, fname } : null));
|
||||
}));
|
||||
const frame0 = {};
|
||||
results.forEach((x) => { if (x) frame0[x.layer] = x.fname; });
|
||||
if (Object.keys(frame0).length) byDir[d] = { multi: false, frames: [frame0] };
|
||||
}
|
||||
}
|
||||
return { ok: true, id: charId, byDir };
|
||||
}
|
||||
|
||||
function applyEditLayerManifest(charId, man) {
|
||||
if (!man || !man.byDir || typeof man.byDir !== 'object' || !Object.keys(man.byDir).length) {
|
||||
return false;
|
||||
}
|
||||
editLayerManifest = { ok: true, id: charId, byDir: man.byDir };
|
||||
renderLayerThumbStripsFromManifest(charId);
|
||||
dirs.forEach((dd) => { refreshLayerPreviewForDir(dd); });
|
||||
return true;
|
||||
}
|
||||
|
||||
function fetchUrlAsDataUrl(fullUrl) {
|
||||
return fetch(fullUrl, { credentials: 'same-origin' })
|
||||
.then((r) => {
|
||||
@@ -129,9 +73,9 @@
|
||||
|
||||
function renderLayerThumbStripsFromManifest(charId) {
|
||||
clearLayerThumbStrips();
|
||||
if (!editLayerManifest || !editLayerManifest.byDir) return;
|
||||
if (!editLayerManifest || (!editLayerManifest.byDir && !editLayerManifest.byDirIdle)) return;
|
||||
dirs.forEach((d) => {
|
||||
const entry = editLayerManifest.byDir[d];
|
||||
const entry = editLayerManifest.byDir && editLayerManifest.byDir[d];
|
||||
if (!entry || !entry.frames || !entry.frames.length) return;
|
||||
layers.forEach((layer) => {
|
||||
const input = layerFileInputs[d] && layerFileInputs[d][layer];
|
||||
@@ -154,6 +98,30 @@
|
||||
if (strip.childNodes.length) input.insertAdjacentElement('afterend', strip);
|
||||
});
|
||||
});
|
||||
dirs.forEach((d) => {
|
||||
const entry = editLayerManifest.byDirIdle && editLayerManifest.byDirIdle[d];
|
||||
if (!entry || !entry.frames || !entry.frames.length) return;
|
||||
layers.forEach((layer) => {
|
||||
const input = idleLayerFileInputs[d] && idleLayerFileInputs[d][layer];
|
||||
if (!input || !input.parentElement) return;
|
||||
const strip = document.createElement('div');
|
||||
strip.className = 'char-layer-seq-strip';
|
||||
strip.setAttribute('data-strip-for', d + '-idle-' + layer);
|
||||
const maxF = entry.frames.length;
|
||||
for (let fi = 0; fi < maxF; fi++) {
|
||||
const fr = entry.frames[fi];
|
||||
const fname = fr && fr[layer];
|
||||
if (!fname) continue;
|
||||
const im = document.createElement('img');
|
||||
im.src = characterLayerFileUrl(fname) + '?v=' + Date.now();
|
||||
im.alt = charId + ' ' + d + ' idle ' + layer + ' f' + fi;
|
||||
im.title = 'idle เฟรม ' + (fi + 1);
|
||||
im.onerror = function () { this.style.opacity = '0.25'; };
|
||||
strip.appendChild(im);
|
||||
}
|
||||
if (strip.childNodes.length) input.insertAdjacentElement('afterend', strip);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getSelected() {
|
||||
@@ -292,6 +260,97 @@
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
async function collectIdleLayerFramesForDirection(dir, maxFrames) {
|
||||
const inputs = idleLayerFileInputs[dir];
|
||||
if (!inputs || maxFrames < 1) return null;
|
||||
const frames = [];
|
||||
for (let i = 0; i < maxFrames; i++) {
|
||||
const layerObj = {};
|
||||
const mf = manifestIdleFrameAt(dir, i);
|
||||
for (const layer of layers) {
|
||||
const input = inputs[layer];
|
||||
const files = input && input.files ? Array.from(input.files) : [];
|
||||
if (files.length) {
|
||||
const file = files[Math.min(i, files.length - 1)];
|
||||
try {
|
||||
layerObj[layer] = await readFileAsDataUrl(file);
|
||||
} catch (e) { /* skip */ }
|
||||
continue;
|
||||
}
|
||||
const fname = mf && mf[layer];
|
||||
if (fname) {
|
||||
try {
|
||||
layerObj[layer] = await fetchUrlAsDataUrl(characterLayerFileUrl(fname));
|
||||
} catch (e) { /* skip */ }
|
||||
}
|
||||
}
|
||||
frames.push(layerObj);
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
async function composeLayeredIdleFrame(dir, frameIndex) {
|
||||
const inputs = idleLayerFileInputs[dir] || {};
|
||||
const mf = manifestIdleFrameAt(dir, frameIndex);
|
||||
const imagePromises = [];
|
||||
layers.forEach(layer => {
|
||||
const input = inputs[layer];
|
||||
const files = input && input.files ? Array.from(input.files) : [];
|
||||
const hasFile = files.length > 0;
|
||||
const file = hasFile ? files[Math.min(frameIndex, files.length - 1)] : null;
|
||||
const fname = mf && mf[layer] ? mf[layer] : null;
|
||||
const urlFromDisk = fname ? characterLayerFileUrl(fname) : '';
|
||||
if (layer === 'shadow') {
|
||||
if (file) {
|
||||
imagePromises.push(readImageFromFile(file));
|
||||
} else if (urlFromDisk) {
|
||||
imagePromises.push(loadImageFromUrl(urlFromDisk).catch(() => null));
|
||||
} else {
|
||||
imagePromises.push(loadImageFromUrl(DEFAULT_SHADOW_URL + dir + '.png').catch(() => null));
|
||||
}
|
||||
} else {
|
||||
if (file) {
|
||||
imagePromises.push(readImageFromFile(file).catch(() => null));
|
||||
} else if (urlFromDisk) {
|
||||
imagePromises.push(loadImageFromUrl(urlFromDisk).catch(() => null));
|
||||
} else {
|
||||
imagePromises.push(Promise.resolve(null));
|
||||
}
|
||||
}
|
||||
});
|
||||
const resolved = await Promise.all(imagePromises);
|
||||
const images = resolved.filter(Boolean);
|
||||
if (!images.length) return null;
|
||||
const base = images[0];
|
||||
const w = base.width || 64;
|
||||
const h = base.height || 64;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
resolved.forEach(img => {
|
||||
if (img) ctx.drawImage(img, 0, 0, w, h);
|
||||
});
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
function refreshIdleLayerPreviewForDir(dir) {
|
||||
const prev = idleLayerPreviews[dir];
|
||||
if (!prev) return;
|
||||
composeLayeredIdleFrame(dir, 0).then(dataUrl => {
|
||||
if (dataUrl) {
|
||||
prev.src = dataUrl;
|
||||
prev.title = 'ตัวอย่าง idle เฟรมที่ 1';
|
||||
} else {
|
||||
prev.src = '';
|
||||
prev.title = '';
|
||||
}
|
||||
}).catch(() => {
|
||||
prev.src = '';
|
||||
prev.title = '';
|
||||
});
|
||||
}
|
||||
|
||||
function refreshLayerPreviewForDir(dir) {
|
||||
const prev = layerPreviews[dir];
|
||||
if (!prev) return;
|
||||
@@ -322,6 +381,17 @@
|
||||
});
|
||||
});
|
||||
});
|
||||
dirs.forEach(d => {
|
||||
const inputs = idleLayerFileInputs[d];
|
||||
if (!inputs) return;
|
||||
layers.forEach(layer => {
|
||||
const input = inputs[layer];
|
||||
if (!input) return;
|
||||
input.addEventListener('change', () => {
|
||||
refreshIdleLayerPreviewForDir(d);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function refreshList() {
|
||||
if (status) status.textContent = 'โหลดรายการตัวละคร...';
|
||||
@@ -369,68 +439,43 @@
|
||||
const url = BASE + '/img/characters/' + encodeURIComponent(char.id) + '_' + d + '.png' + ts;
|
||||
prev.onerror = function () { prev.src = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56"><rect fill="%2324283b" width="56" height="56"/><text x="28" y="30" fill="%23a9b1d6" font-size="10" text-anchor="middle">ไม่มี</text></svg>'); prev.onerror = null; };
|
||||
prev.src = url;
|
||||
refreshIdleLayerPreviewForDir(d);
|
||||
});
|
||||
editLayerManifest = null;
|
||||
clearLayerThumbStrips();
|
||||
const adv = document.getElementById('char-upload-advanced');
|
||||
const scrollTarget = char.hasLayerFiles && adv ? adv : document.querySelector('.char-upload-simple');
|
||||
if (char.hasLayerFiles) {
|
||||
const setMergedLayerPreviewsOnly = () => {
|
||||
dirs.forEach((d) => {
|
||||
const lp = layerPreviews[d];
|
||||
if (lp) {
|
||||
lp.src = BASE + '/img/characters/' + encodeURIComponent(char.id) + '_' + d + '.png' + ts;
|
||||
lp.onerror = function () { lp.src = ''; lp.onerror = null; };
|
||||
}
|
||||
});
|
||||
};
|
||||
const finishEdit = (fromLayers) => {
|
||||
if (uploadStatus) {
|
||||
uploadStatus.textContent = fromLayers
|
||||
? ('แก้ไข "' + (char.name || char.id) + '" — ดูลำดับ PNG ต่อเลเยอร์ด้านล่าง · เลือกไฟล์ใหม่แล้วกดอัปโหลดเพื่ออัปเดต')
|
||||
: ('แก้ไข "' + (char.name || char.id) + '" — แสดงรูปรวมในตัวอย่างเลเยอร์ (ถ้าไม่มีแถบรูป: รีสตาร์ท Node แล้วรีเฟรช)');
|
||||
}
|
||||
if (scrollTarget) scrollTarget.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
};
|
||||
if (applyEditLayerManifest(char.id, char.layerManifest)) {
|
||||
finishEdit(true);
|
||||
} else {
|
||||
fetch(BASE + '/api/characters/' + encodeURIComponent(char.id) + '/layer-manifest')
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('http ' + r.status))))
|
||||
.then((man) => {
|
||||
if (applyEditLayerManifest(char.id, man)) {
|
||||
finishEdit(true);
|
||||
return LAYER_EDIT_FLOW_DONE;
|
||||
}
|
||||
return buildLayerManifestByProbing(char.id);
|
||||
})
|
||||
.then((man2) => {
|
||||
if (man2 === LAYER_EDIT_FLOW_DONE) return;
|
||||
if (man2 && applyEditLayerManifest(char.id, man2)) finishEdit(true);
|
||||
else {
|
||||
editLayerManifest = null;
|
||||
clearLayerThumbStrips();
|
||||
setMergedLayerPreviewsOnly();
|
||||
finishEdit(false);
|
||||
}
|
||||
})
|
||||
.catch(() => buildLayerManifestByProbing(char.id)
|
||||
.then((man3) => {
|
||||
if (man3 && applyEditLayerManifest(char.id, man3)) finishEdit(true);
|
||||
else {
|
||||
editLayerManifest = null;
|
||||
clearLayerThumbStrips();
|
||||
setMergedLayerPreviewsOnly();
|
||||
finishEdit(false);
|
||||
fetch(BASE + '/api/characters/' + encodeURIComponent(char.id) + '/layer-manifest')
|
||||
.then((r) => r.json())
|
||||
.then((man) => {
|
||||
if (man && man.ok && (man.byDir || man.byDirIdle)) {
|
||||
editLayerManifest = man;
|
||||
if (!editLayerManifest.byDir) editLayerManifest.byDir = {};
|
||||
if (!editLayerManifest.byDirIdle) editLayerManifest.byDirIdle = {};
|
||||
renderLayerThumbStripsFromManifest(char.id);
|
||||
dirs.forEach((d) => {
|
||||
refreshLayerPreviewForDir(d);
|
||||
refreshIdleLayerPreviewForDir(d);
|
||||
});
|
||||
} else {
|
||||
dirs.forEach((d) => {
|
||||
const lp = layerPreviews[d];
|
||||
if (lp) {
|
||||
lp.src = BASE + '/img/characters/' + encodeURIComponent(char.id) + '_' + d + '.png' + ts;
|
||||
lp.onerror = function () { lp.src = ''; lp.onerror = null; };
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
editLayerManifest = null;
|
||||
clearLayerThumbStrips();
|
||||
setMergedLayerPreviewsOnly();
|
||||
finishEdit(false);
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
if (uploadStatus) {
|
||||
uploadStatus.textContent = 'แก้ไข "' + (char.name || char.id) + '" — ดูลำดับ PNG ต่อเลเยอร์ด้านล่าง · เลือกไฟล์ใหม่แล้วกดอัปโหลดเพื่ออัปเดต';
|
||||
}
|
||||
if (scrollTarget) scrollTarget.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
})
|
||||
.catch(() => {
|
||||
if (uploadStatus) uploadStatus.textContent = 'โหลดรายการเลเยอร์ไม่ได้ — แสดงเฉพาะรูปรวมด้านบน';
|
||||
if (scrollTarget) scrollTarget.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
} else {
|
||||
dirs.forEach((d) => {
|
||||
const lp = layerPreviews[d];
|
||||
@@ -512,6 +557,14 @@
|
||||
delete payload[d];
|
||||
}
|
||||
});
|
||||
if (payload.idle && typeof payload.idle === 'object') {
|
||||
dirs.forEach((d) => {
|
||||
const v = payload.idle[d];
|
||||
if (Array.isArray(v) && v.length === 1) payload.idle[d] = v[0];
|
||||
if (Array.isArray(v) && v.length === 0) delete payload.idle[d];
|
||||
});
|
||||
if (!Object.keys(payload.idle).length) delete payload.idle;
|
||||
}
|
||||
if (uploadStatus) uploadStatus.textContent = 'กำลังอัปโหลด...';
|
||||
return fetch(BASE + '/api/characters/upload', {
|
||||
method: 'POST',
|
||||
@@ -528,7 +581,8 @@
|
||||
return;
|
||||
}
|
||||
if (data.ok) {
|
||||
if (uploadStatus) uploadStatus.textContent = 'อัปโหลดสำเร็จ: ' + (data.characterId || '');
|
||||
const savedId = data.characterId || '';
|
||||
if (uploadStatus) uploadStatus.textContent = 'อัปโหลดสำเร็จ: ' + savedId;
|
||||
editLayerManifest = null;
|
||||
clearLayerThumbStrips();
|
||||
dirs.forEach(d => {
|
||||
@@ -540,10 +594,34 @@
|
||||
if (inputs[layer]) inputs[layer].value = '';
|
||||
});
|
||||
}
|
||||
const idleIn = idleLayerFileInputs[d];
|
||||
if (idleIn) {
|
||||
layers.forEach(layer => {
|
||||
if (idleIn[layer]) idleIn[layer].value = '';
|
||||
});
|
||||
}
|
||||
if (idleLayerPreviews[d]) { idleLayerPreviews[d].src = ''; idleLayerPreviews[d].title = ''; }
|
||||
if (layerPreviews[d]) layerPreviews[d].src = '';
|
||||
});
|
||||
if (charName) charName.value = '';
|
||||
if (charName) charName.value = savedId;
|
||||
refreshList();
|
||||
if (savedId) {
|
||||
fetch(BASE + '/api/characters/' + encodeURIComponent(savedId) + '/layer-manifest')
|
||||
.then((r) => r.json())
|
||||
.then((man) => {
|
||||
if (man && man.ok && (man.byDir || man.byDirIdle)) {
|
||||
editLayerManifest = man;
|
||||
if (!editLayerManifest.byDir) editLayerManifest.byDir = {};
|
||||
if (!editLayerManifest.byDirIdle) editLayerManifest.byDirIdle = {};
|
||||
renderLayerThumbStripsFromManifest(savedId);
|
||||
dirs.forEach((d) => {
|
||||
refreshLayerPreviewForDir(d);
|
||||
refreshIdleLayerPreviewForDir(d);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
} else {
|
||||
if (uploadStatus) uploadStatus.textContent = data.error || 'อัปโหลดไม่สำเร็จ';
|
||||
}
|
||||
@@ -556,14 +634,14 @@
|
||||
|
||||
if (btnUpload) {
|
||||
btnUpload.addEventListener('click', () => {
|
||||
const payload = { name: (charName && charName.value || '').trim() };
|
||||
let hasAny = false;
|
||||
const payload = { name: (charName && charName.value || '').trim() || getSelected() || '' };
|
||||
let hasWalk = false;
|
||||
const readers = [];
|
||||
dirs.forEach(d => {
|
||||
const input = fileInputs[d];
|
||||
const files = input && input.files ? Array.from(input.files) : [];
|
||||
if (!files.length) return;
|
||||
hasAny = true;
|
||||
hasWalk = true;
|
||||
payload[d] = [];
|
||||
files.forEach((f, idx) => {
|
||||
readers.push(new Promise(resolve => {
|
||||
@@ -573,8 +651,8 @@
|
||||
}));
|
||||
});
|
||||
});
|
||||
if (!hasAny) {
|
||||
if (uploadStatus) uploadStatus.textContent = 'กรุณาเลือกรูปอย่างน้อย 1 ทิศ';
|
||||
if (!hasWalk) {
|
||||
if (uploadStatus) uploadStatus.textContent = 'กรุณาเลือกรูปเดินอย่างน้อย 1 ทิศ (idle เป็นทางเลือก)';
|
||||
return;
|
||||
}
|
||||
Promise.all(readers).then(() => {
|
||||
@@ -585,7 +663,7 @@
|
||||
|
||||
if (btnUploadLayered) {
|
||||
btnUploadLayered.addEventListener('click', () => {
|
||||
const payload = { name: (charName && charName.value || '').trim() };
|
||||
const payload = { name: (charName && charName.value || '').trim() || getSelected() || '' };
|
||||
let hasAny = false;
|
||||
const tasks = [];
|
||||
const layerCollectTasks = [];
|
||||
@@ -628,9 +706,65 @@
|
||||
return;
|
||||
}
|
||||
if (uploadStatus) uploadStatus.textContent = 'กำลังรวมเลเยอร์และอัปโหลด...';
|
||||
Promise.all([...tasks, ...layerCollectTasks]).then(() => {
|
||||
const idleLayerCollectTasks = [];
|
||||
const idleTasks = [];
|
||||
dirs.forEach(d => {
|
||||
const inputs = idleLayerFileInputs[d];
|
||||
if (!inputs) return;
|
||||
let maxIdle = 0;
|
||||
layers.forEach(layer => {
|
||||
const input = inputs[layer];
|
||||
const len = input && input.files ? input.files.length : 0;
|
||||
if (len > maxIdle) maxIdle = len;
|
||||
});
|
||||
const manEntIdle = editLayerManifest && editLayerManifest.byDirIdle && editLayerManifest.byDirIdle[d];
|
||||
if (manEntIdle && Array.isArray(manEntIdle.frames) && manEntIdle.frames.length > maxIdle) {
|
||||
maxIdle = manEntIdle.frames.length;
|
||||
}
|
||||
if (maxIdle === 0) return;
|
||||
payload.idle = payload.idle || {};
|
||||
payload.idle[d] = [];
|
||||
idleLayerCollectTasks.push(
|
||||
collectIdleLayerFramesForDirection(d, maxIdle).then((lf) => {
|
||||
if (lf && lf.some(fr => fr && Object.keys(fr).length > 0)) {
|
||||
payload.layerFramesIdle = payload.layerFramesIdle || {};
|
||||
payload.layerFramesIdle[d] = lf;
|
||||
}
|
||||
})
|
||||
);
|
||||
for (let i = 0; i < maxIdle; i++) {
|
||||
idleTasks.push(
|
||||
composeLayeredIdleFrame(d, i).then(dataUrl => {
|
||||
if (dataUrl) payload.idle[d][i] = dataUrl;
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
Promise.all([...tasks, ...layerCollectTasks, ...idleTasks, ...idleLayerCollectTasks]).then(() => {
|
||||
uploadCharacterPayload(payload);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
(function wireAdvancedLayerModeTabs() {
|
||||
const adv = document.getElementById('char-upload-advanced');
|
||||
const bar = document.getElementById('char-layer-global-tabs');
|
||||
if (!adv || !bar) return;
|
||||
function setMode(mode) {
|
||||
const m = mode === 'idle' ? 'idle' : 'move';
|
||||
adv.setAttribute('data-adv-layer-mode', m);
|
||||
bar.querySelectorAll('.char-mode-tab').forEach((btn) => {
|
||||
const on = btn.getAttribute('data-adv-mode') === m;
|
||||
btn.classList.toggle('is-active', on);
|
||||
btn.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
bar.addEventListener('click', (ev) => {
|
||||
const t = ev.target.closest('[data-adv-mode]');
|
||||
if (!t) return;
|
||||
const mode = t.getAttribute('data-adv-mode');
|
||||
if (mode === 'move' || mode === 'idle') setMode(mode);
|
||||
});
|
||||
setMode(adv.getAttribute('data-adv-layer-mode') || 'move');
|
||||
})();
|
||||
})();
|
||||
|
||||
@@ -11,11 +11,19 @@
|
||||
const DEFAULT_SHADOW_URL = BASE + '/img/default-shadow-';
|
||||
const fileInputs = {};
|
||||
const previews = {};
|
||||
const idleLayerFileInputs = {};
|
||||
const idleLayerPreviews = {};
|
||||
const layerFileInputs = {};
|
||||
const layerPreviews = {};
|
||||
dirs.forEach(d => {
|
||||
fileInputs[d] = document.getElementById('file-' + d);
|
||||
previews[d] = document.getElementById('preview-' + d);
|
||||
idleLayerFileInputs[d] = {};
|
||||
layers.forEach(layer => {
|
||||
const el = document.getElementById('layer-idle-' + d + '-' + layer);
|
||||
if (el) idleLayerFileInputs[d][layer] = el;
|
||||
});
|
||||
idleLayerPreviews[d] = document.getElementById('preview-idle-layer-' + d);
|
||||
layerFileInputs[d] = {};
|
||||
layers.forEach(layer => {
|
||||
const inputId = 'layer-' + d + '-' + layer;
|
||||
@@ -38,6 +46,12 @@
|
||||
return e.frames[frameIndex];
|
||||
}
|
||||
|
||||
function manifestIdleFrameAt(dir, frameIndex) {
|
||||
const e = editLayerManifest && editLayerManifest.byDirIdle && editLayerManifest.byDirIdle[dir];
|
||||
if (!e || !e.frames || !e.frames[frameIndex]) return null;
|
||||
return e.frames[frameIndex];
|
||||
}
|
||||
|
||||
function characterLayerFileUrl(fname) {
|
||||
if (!fname || typeof fname !== 'string') return '';
|
||||
return BASE + '/img/characters/' + encodeURIComponent(fname);
|
||||
@@ -59,9 +73,9 @@
|
||||
|
||||
function renderLayerThumbStripsFromManifest(charId) {
|
||||
clearLayerThumbStrips();
|
||||
if (!editLayerManifest || !editLayerManifest.byDir) return;
|
||||
if (!editLayerManifest || (!editLayerManifest.byDir && !editLayerManifest.byDirIdle)) return;
|
||||
dirs.forEach((d) => {
|
||||
const entry = editLayerManifest.byDir[d];
|
||||
const entry = editLayerManifest.byDir && editLayerManifest.byDir[d];
|
||||
if (!entry || !entry.frames || !entry.frames.length) return;
|
||||
layers.forEach((layer) => {
|
||||
const input = layerFileInputs[d] && layerFileInputs[d][layer];
|
||||
@@ -84,6 +98,30 @@
|
||||
if (strip.childNodes.length) input.insertAdjacentElement('afterend', strip);
|
||||
});
|
||||
});
|
||||
dirs.forEach((d) => {
|
||||
const entry = editLayerManifest.byDirIdle && editLayerManifest.byDirIdle[d];
|
||||
if (!entry || !entry.frames || !entry.frames.length) return;
|
||||
layers.forEach((layer) => {
|
||||
const input = idleLayerFileInputs[d] && idleLayerFileInputs[d][layer];
|
||||
if (!input || !input.parentElement) return;
|
||||
const strip = document.createElement('div');
|
||||
strip.className = 'char-layer-seq-strip';
|
||||
strip.setAttribute('data-strip-for', d + '-idle-' + layer);
|
||||
const maxF = entry.frames.length;
|
||||
for (let fi = 0; fi < maxF; fi++) {
|
||||
const fr = entry.frames[fi];
|
||||
const fname = fr && fr[layer];
|
||||
if (!fname) continue;
|
||||
const im = document.createElement('img');
|
||||
im.src = characterLayerFileUrl(fname) + '?v=' + Date.now();
|
||||
im.alt = charId + ' ' + d + ' idle ' + layer + ' f' + fi;
|
||||
im.title = 'idle เฟรม ' + (fi + 1);
|
||||
im.onerror = function () { this.style.opacity = '0.25'; };
|
||||
strip.appendChild(im);
|
||||
}
|
||||
if (strip.childNodes.length) input.insertAdjacentElement('afterend', strip);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getSelected() {
|
||||
@@ -222,6 +260,97 @@
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
async function collectIdleLayerFramesForDirection(dir, maxFrames) {
|
||||
const inputs = idleLayerFileInputs[dir];
|
||||
if (!inputs || maxFrames < 1) return null;
|
||||
const frames = [];
|
||||
for (let i = 0; i < maxFrames; i++) {
|
||||
const layerObj = {};
|
||||
const mf = manifestIdleFrameAt(dir, i);
|
||||
for (const layer of layers) {
|
||||
const input = inputs[layer];
|
||||
const files = input && input.files ? Array.from(input.files) : [];
|
||||
if (files.length) {
|
||||
const file = files[Math.min(i, files.length - 1)];
|
||||
try {
|
||||
layerObj[layer] = await readFileAsDataUrl(file);
|
||||
} catch (e) { /* skip */ }
|
||||
continue;
|
||||
}
|
||||
const fname = mf && mf[layer];
|
||||
if (fname) {
|
||||
try {
|
||||
layerObj[layer] = await fetchUrlAsDataUrl(characterLayerFileUrl(fname));
|
||||
} catch (e) { /* skip */ }
|
||||
}
|
||||
}
|
||||
frames.push(layerObj);
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
async function composeLayeredIdleFrame(dir, frameIndex) {
|
||||
const inputs = idleLayerFileInputs[dir] || {};
|
||||
const mf = manifestIdleFrameAt(dir, frameIndex);
|
||||
const imagePromises = [];
|
||||
layers.forEach(layer => {
|
||||
const input = inputs[layer];
|
||||
const files = input && input.files ? Array.from(input.files) : [];
|
||||
const hasFile = files.length > 0;
|
||||
const file = hasFile ? files[Math.min(frameIndex, files.length - 1)] : null;
|
||||
const fname = mf && mf[layer] ? mf[layer] : null;
|
||||
const urlFromDisk = fname ? characterLayerFileUrl(fname) : '';
|
||||
if (layer === 'shadow') {
|
||||
if (file) {
|
||||
imagePromises.push(readImageFromFile(file));
|
||||
} else if (urlFromDisk) {
|
||||
imagePromises.push(loadImageFromUrl(urlFromDisk).catch(() => null));
|
||||
} else {
|
||||
imagePromises.push(loadImageFromUrl(DEFAULT_SHADOW_URL + dir + '.png').catch(() => null));
|
||||
}
|
||||
} else {
|
||||
if (file) {
|
||||
imagePromises.push(readImageFromFile(file).catch(() => null));
|
||||
} else if (urlFromDisk) {
|
||||
imagePromises.push(loadImageFromUrl(urlFromDisk).catch(() => null));
|
||||
} else {
|
||||
imagePromises.push(Promise.resolve(null));
|
||||
}
|
||||
}
|
||||
});
|
||||
const resolved = await Promise.all(imagePromises);
|
||||
const images = resolved.filter(Boolean);
|
||||
if (!images.length) return null;
|
||||
const base = images[0];
|
||||
const w = base.width || 64;
|
||||
const h = base.height || 64;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
resolved.forEach(img => {
|
||||
if (img) ctx.drawImage(img, 0, 0, w, h);
|
||||
});
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
function refreshIdleLayerPreviewForDir(dir) {
|
||||
const prev = idleLayerPreviews[dir];
|
||||
if (!prev) return;
|
||||
composeLayeredIdleFrame(dir, 0).then(dataUrl => {
|
||||
if (dataUrl) {
|
||||
prev.src = dataUrl;
|
||||
prev.title = 'ตัวอย่าง idle เฟรมที่ 1';
|
||||
} else {
|
||||
prev.src = '';
|
||||
prev.title = '';
|
||||
}
|
||||
}).catch(() => {
|
||||
prev.src = '';
|
||||
prev.title = '';
|
||||
});
|
||||
}
|
||||
|
||||
function refreshLayerPreviewForDir(dir) {
|
||||
const prev = layerPreviews[dir];
|
||||
if (!prev) return;
|
||||
@@ -252,6 +381,17 @@
|
||||
});
|
||||
});
|
||||
});
|
||||
dirs.forEach(d => {
|
||||
const inputs = idleLayerFileInputs[d];
|
||||
if (!inputs) return;
|
||||
layers.forEach(layer => {
|
||||
const input = inputs[layer];
|
||||
if (!input) return;
|
||||
input.addEventListener('change', () => {
|
||||
refreshIdleLayerPreviewForDir(d);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function refreshList() {
|
||||
if (status) status.textContent = 'โหลดรายการตัวละคร...';
|
||||
@@ -299,6 +439,7 @@
|
||||
const url = BASE + '/img/characters/' + encodeURIComponent(char.id) + '_' + d + '.png' + ts;
|
||||
prev.onerror = function () { prev.src = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56"><rect fill="%2324283b" width="56" height="56"/><text x="28" y="30" fill="%23a9b1d6" font-size="10" text-anchor="middle">ไม่มี</text></svg>'); prev.onerror = null; };
|
||||
prev.src = url;
|
||||
refreshIdleLayerPreviewForDir(d);
|
||||
});
|
||||
editLayerManifest = null;
|
||||
clearLayerThumbStrips();
|
||||
@@ -308,10 +449,15 @@
|
||||
fetch(BASE + '/api/characters/' + encodeURIComponent(char.id) + '/layer-manifest')
|
||||
.then((r) => r.json())
|
||||
.then((man) => {
|
||||
if (man && man.ok && man.byDir) {
|
||||
if (man && man.ok && (man.byDir || man.byDirIdle)) {
|
||||
editLayerManifest = man;
|
||||
if (!editLayerManifest.byDir) editLayerManifest.byDir = {};
|
||||
if (!editLayerManifest.byDirIdle) editLayerManifest.byDirIdle = {};
|
||||
renderLayerThumbStripsFromManifest(char.id);
|
||||
dirs.forEach((d) => { refreshLayerPreviewForDir(d); });
|
||||
dirs.forEach((d) => {
|
||||
refreshLayerPreviewForDir(d);
|
||||
refreshIdleLayerPreviewForDir(d);
|
||||
});
|
||||
} else {
|
||||
dirs.forEach((d) => {
|
||||
const lp = layerPreviews[d];
|
||||
@@ -411,6 +557,14 @@
|
||||
delete payload[d];
|
||||
}
|
||||
});
|
||||
if (payload.idle && typeof payload.idle === 'object') {
|
||||
dirs.forEach((d) => {
|
||||
const v = payload.idle[d];
|
||||
if (Array.isArray(v) && v.length === 1) payload.idle[d] = v[0];
|
||||
if (Array.isArray(v) && v.length === 0) delete payload.idle[d];
|
||||
});
|
||||
if (!Object.keys(payload.idle).length) delete payload.idle;
|
||||
}
|
||||
if (uploadStatus) uploadStatus.textContent = 'กำลังอัปโหลด...';
|
||||
return fetch(BASE + '/api/characters/upload', {
|
||||
method: 'POST',
|
||||
@@ -427,7 +581,8 @@
|
||||
return;
|
||||
}
|
||||
if (data.ok) {
|
||||
if (uploadStatus) uploadStatus.textContent = 'อัปโหลดสำเร็จ: ' + (data.characterId || '');
|
||||
const savedId = data.characterId || '';
|
||||
if (uploadStatus) uploadStatus.textContent = 'อัปโหลดสำเร็จ: ' + savedId;
|
||||
editLayerManifest = null;
|
||||
clearLayerThumbStrips();
|
||||
dirs.forEach(d => {
|
||||
@@ -439,10 +594,34 @@
|
||||
if (inputs[layer]) inputs[layer].value = '';
|
||||
});
|
||||
}
|
||||
const idleIn = idleLayerFileInputs[d];
|
||||
if (idleIn) {
|
||||
layers.forEach(layer => {
|
||||
if (idleIn[layer]) idleIn[layer].value = '';
|
||||
});
|
||||
}
|
||||
if (idleLayerPreviews[d]) { idleLayerPreviews[d].src = ''; idleLayerPreviews[d].title = ''; }
|
||||
if (layerPreviews[d]) layerPreviews[d].src = '';
|
||||
});
|
||||
if (charName) charName.value = '';
|
||||
if (charName) charName.value = savedId;
|
||||
refreshList();
|
||||
if (savedId) {
|
||||
fetch(BASE + '/api/characters/' + encodeURIComponent(savedId) + '/layer-manifest')
|
||||
.then((r) => r.json())
|
||||
.then((man) => {
|
||||
if (man && man.ok && (man.byDir || man.byDirIdle)) {
|
||||
editLayerManifest = man;
|
||||
if (!editLayerManifest.byDir) editLayerManifest.byDir = {};
|
||||
if (!editLayerManifest.byDirIdle) editLayerManifest.byDirIdle = {};
|
||||
renderLayerThumbStripsFromManifest(savedId);
|
||||
dirs.forEach((d) => {
|
||||
refreshLayerPreviewForDir(d);
|
||||
refreshIdleLayerPreviewForDir(d);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
} else {
|
||||
if (uploadStatus) uploadStatus.textContent = data.error || 'อัปโหลดไม่สำเร็จ';
|
||||
}
|
||||
@@ -455,14 +634,14 @@
|
||||
|
||||
if (btnUpload) {
|
||||
btnUpload.addEventListener('click', () => {
|
||||
const payload = { name: (charName && charName.value || '').trim() };
|
||||
let hasAny = false;
|
||||
const payload = { name: (charName && charName.value || '').trim() || getSelected() || '' };
|
||||
let hasWalk = false;
|
||||
const readers = [];
|
||||
dirs.forEach(d => {
|
||||
const input = fileInputs[d];
|
||||
const files = input && input.files ? Array.from(input.files) : [];
|
||||
if (!files.length) return;
|
||||
hasAny = true;
|
||||
hasWalk = true;
|
||||
payload[d] = [];
|
||||
files.forEach((f, idx) => {
|
||||
readers.push(new Promise(resolve => {
|
||||
@@ -472,8 +651,8 @@
|
||||
}));
|
||||
});
|
||||
});
|
||||
if (!hasAny) {
|
||||
if (uploadStatus) uploadStatus.textContent = 'กรุณาเลือกรูปอย่างน้อย 1 ทิศ';
|
||||
if (!hasWalk) {
|
||||
if (uploadStatus) uploadStatus.textContent = 'กรุณาเลือกรูปเดินอย่างน้อย 1 ทิศ (idle เป็นทางเลือก)';
|
||||
return;
|
||||
}
|
||||
Promise.all(readers).then(() => {
|
||||
@@ -484,7 +663,7 @@
|
||||
|
||||
if (btnUploadLayered) {
|
||||
btnUploadLayered.addEventListener('click', () => {
|
||||
const payload = { name: (charName && charName.value || '').trim() };
|
||||
const payload = { name: (charName && charName.value || '').trim() || getSelected() || '' };
|
||||
let hasAny = false;
|
||||
const tasks = [];
|
||||
const layerCollectTasks = [];
|
||||
@@ -527,9 +706,65 @@
|
||||
return;
|
||||
}
|
||||
if (uploadStatus) uploadStatus.textContent = 'กำลังรวมเลเยอร์และอัปโหลด...';
|
||||
Promise.all([...tasks, ...layerCollectTasks]).then(() => {
|
||||
const idleLayerCollectTasks = [];
|
||||
const idleTasks = [];
|
||||
dirs.forEach(d => {
|
||||
const inputs = idleLayerFileInputs[d];
|
||||
if (!inputs) return;
|
||||
let maxIdle = 0;
|
||||
layers.forEach(layer => {
|
||||
const input = inputs[layer];
|
||||
const len = input && input.files ? input.files.length : 0;
|
||||
if (len > maxIdle) maxIdle = len;
|
||||
});
|
||||
const manEntIdle = editLayerManifest && editLayerManifest.byDirIdle && editLayerManifest.byDirIdle[d];
|
||||
if (manEntIdle && Array.isArray(manEntIdle.frames) && manEntIdle.frames.length > maxIdle) {
|
||||
maxIdle = manEntIdle.frames.length;
|
||||
}
|
||||
if (maxIdle === 0) return;
|
||||
payload.idle = payload.idle || {};
|
||||
payload.idle[d] = [];
|
||||
idleLayerCollectTasks.push(
|
||||
collectIdleLayerFramesForDirection(d, maxIdle).then((lf) => {
|
||||
if (lf && lf.some(fr => fr && Object.keys(fr).length > 0)) {
|
||||
payload.layerFramesIdle = payload.layerFramesIdle || {};
|
||||
payload.layerFramesIdle[d] = lf;
|
||||
}
|
||||
})
|
||||
);
|
||||
for (let i = 0; i < maxIdle; i++) {
|
||||
idleTasks.push(
|
||||
composeLayeredIdleFrame(d, i).then(dataUrl => {
|
||||
if (dataUrl) payload.idle[d][i] = dataUrl;
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
Promise.all([...tasks, ...layerCollectTasks, ...idleTasks, ...idleLayerCollectTasks]).then(() => {
|
||||
uploadCharacterPayload(payload);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
(function wireAdvancedLayerModeTabs() {
|
||||
const adv = document.getElementById('char-upload-advanced');
|
||||
const bar = document.getElementById('char-layer-global-tabs');
|
||||
if (!adv || !bar) return;
|
||||
function setMode(mode) {
|
||||
const m = mode === 'idle' ? 'idle' : 'move';
|
||||
adv.setAttribute('data-adv-layer-mode', m);
|
||||
bar.querySelectorAll('.char-mode-tab').forEach((btn) => {
|
||||
const on = btn.getAttribute('data-adv-mode') === m;
|
||||
btn.classList.toggle('is-active', on);
|
||||
btn.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
bar.addEventListener('click', (ev) => {
|
||||
const t = ev.target.closest('[data-adv-mode]');
|
||||
if (!t) return;
|
||||
const mode = t.getAttribute('data-adv-mode');
|
||||
if (mode === 'move' || mode === 'idle') setMode(mode);
|
||||
});
|
||||
setMode(adv.getAttribute('data-adv-layer-mode') || 'move');
|
||||
})();
|
||||
})();
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
}
|
||||
const defaultAvatarImg = createDefaultAvatarImg();
|
||||
const characterAnimations = {};
|
||||
const characterIdleAnimations = {};
|
||||
const CHARACTER_ANIM_FRAMES = 4;
|
||||
const CHARACTER_ANIM_FRAME_MS = 200;
|
||||
|
||||
@@ -158,6 +159,43 @@
|
||||
return -1;
|
||||
}
|
||||
|
||||
function ensureCharacterIdleAnim(id, dir) {
|
||||
const d = dir || 'down';
|
||||
const key = id + '_' + d + '_idle';
|
||||
let anim = characterIdleAnimations[key];
|
||||
if (!anim) {
|
||||
anim = { frames: [], fallback: null };
|
||||
characterIdleAnimations[key] = anim;
|
||||
const enc = encodeURIComponent(id);
|
||||
for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) {
|
||||
const img = new Image();
|
||||
img.src = BASE + '/img/characters/' + enc + '_' + d + '_idle_' + i + '.png';
|
||||
anim.frames.push(img);
|
||||
}
|
||||
const fb = new Image();
|
||||
fb.src = BASE + '/img/characters/' + enc + '_' + d + '_idle.png';
|
||||
anim.fallback = fb;
|
||||
}
|
||||
return anim;
|
||||
}
|
||||
|
||||
/** ลูป idle ขณะยืน — ใช้จังหวะเวลาเหมือนเดิน (CHARACTER_ANIM_FRAME_MS) */
|
||||
function idleAnimPhaseIndex(now) {
|
||||
const t = typeof now === 'number' ? now : Date.now();
|
||||
return Math.floor(t / CHARACTER_ANIM_FRAME_MS) % CHARACTER_ANIM_FRAMES;
|
||||
}
|
||||
|
||||
function characterIdleSpritesVisible(id, dir) {
|
||||
if (!id) return false;
|
||||
const anim = ensureCharacterIdleAnim(id, dir || 'down');
|
||||
if (anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return true;
|
||||
for (let i = 0; i < anim.frames.length; i++) {
|
||||
const f = anim.frames[i];
|
||||
if (f && f.complete && f.naturalWidth) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** สุ่มสีตามเลเยอร์เดียวกับ character.html: bodyColor / hairColor / headColor (ลำดับซ้อนเหมือน composeLayeredFrame) */
|
||||
const PLAY_LAYER_ORDER = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face'];
|
||||
const PLAY_LAYER_TINT_KEY = { bodyColor: 'body', headColor: 'head', hairColor: 'hair' };
|
||||
@@ -320,6 +358,20 @@
|
||||
return out;
|
||||
}
|
||||
|
||||
/** เลเยอร์ idle: id_<dir>_idle_<n>_layer_<name>.png หรือ id_<dir>_idle_layer_<name>.png */
|
||||
function layerUrlCandidatesIdle(id, dir, layerName, frameIndex) {
|
||||
const enc = encodeURIComponent(id);
|
||||
const base = BASE + '/img/characters/' + enc + '_' + dir + '_idle';
|
||||
const out = [];
|
||||
function add(u) {
|
||||
if (out.indexOf(u) === -1) out.push(u);
|
||||
}
|
||||
add(base + '_' + frameIndex + '_layer_' + layerName + '.png');
|
||||
if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png');
|
||||
add(base + '_layer_' + layerName + '.png');
|
||||
return out;
|
||||
}
|
||||
|
||||
function shadowUrlCandidates(id, dir, frameIndex) {
|
||||
const c = layerUrlCandidates(id, dir, 'shadow', frameIndex);
|
||||
const def = BASE + '/img/default-shadow-' + dir + '.png';
|
||||
@@ -327,6 +379,13 @@
|
||||
return c;
|
||||
}
|
||||
|
||||
function shadowUrlCandidatesIdle(id, dir, frameIndex) {
|
||||
const c = layerUrlCandidatesIdle(id, dir, 'shadow', frameIndex);
|
||||
const def = BASE + '/img/default-shadow-' + dir + '.png';
|
||||
if (c.indexOf(def) === -1) c.push(def);
|
||||
return c;
|
||||
}
|
||||
|
||||
/** ลองทีละ URL — อย่า set src พร้อมกันหลายรูป (เดิมทำให้ legacy `id_dir_layer_x` โดน 404 ทุกเลเยอร์ทุกทิศทั้งที่มีแค่ `id_dir_0_layer_x`) */
|
||||
function resolvePlayLayerImage(urls) {
|
||||
for (let i = 0; i < urls.length; i++) {
|
||||
@@ -400,7 +459,8 @@
|
||||
return fi >= 0 ? fi : 0;
|
||||
}
|
||||
|
||||
function tryComposePlayLayersFromFiles(rawImg, id, dir, frameIndex, tint) {
|
||||
function tryComposePlayLayersFromFiles(rawImg, id, dir, frameIndex, tint, opts) {
|
||||
const useIdle = opts && opts.idle;
|
||||
if (!rawImg || !rawImg.naturalWidth || !rawImg.naturalHeight) return null;
|
||||
const w = rawImg.naturalWidth, h = rawImg.naturalHeight;
|
||||
const c = document.createElement('canvas');
|
||||
@@ -415,8 +475,8 @@
|
||||
if (diskLayers && layerName !== 'shadow' && !diskLayers.has(layerName)) continue;
|
||||
/* ห้าม fallback face จากทิศ down เมื่อเป็น up/left/right — จะได้ตาอยู่หลังหัวเพราะวางหน้าหน้าบนร่างหันหลัง */
|
||||
const urls = layerName === 'shadow'
|
||||
? shadowUrlCandidates(id, dir, frameIndex)
|
||||
: layerUrlCandidates(id, dir, layerName, frameIndex);
|
||||
? (useIdle ? shadowUrlCandidatesIdle(id, dir, frameIndex) : shadowUrlCandidates(id, dir, frameIndex))
|
||||
: (useIdle ? layerUrlCandidatesIdle(id, dir, layerName, frameIndex) : layerUrlCandidates(id, dir, layerName, frameIndex));
|
||||
const r = resolvePlayLayerImage(urls);
|
||||
/* เงาโหลดช้า/404 บ่อย — อย่าให้บล็อกทั้งคอมโพส (ไม่งั้นตกไป fallback แถบแนวตั้งแทนเลเยอร์จริง) */
|
||||
if (r.status === 'pending' && layerName === 'shadow') {
|
||||
@@ -438,23 +498,40 @@
|
||||
if (!tint || !characterId || !rawImg) return rawImg;
|
||||
ensurePlayCharLayerListFetch();
|
||||
ensurePlayLayerProbesAllDirections(characterId);
|
||||
const frameIndex = getCharacterAnimFrameIndex(characterId, dir, timeMs, isWalking);
|
||||
const dirn = dir || 'down';
|
||||
const frameIndexWalk = getCharacterAnimFrameIndex(characterId, dirn, timeMs, isWalking);
|
||||
const idlePhase = idleAnimPhaseIndex(typeof timeMs === 'number' ? timeMs : Date.now());
|
||||
|
||||
if (playCharLayerDiscoveryPending(characterId)) return rawImg;
|
||||
|
||||
/* มี PNG idle รวม — อย่าทับด้วยคอมโพสเลเยอร์จากเฟรมเดิน (idle เป็นภาพรวมแล้ว) */
|
||||
if (!isWalking && characterIdleSpritesVisible(characterId, dirn)) {
|
||||
return rawImg;
|
||||
}
|
||||
|
||||
const apiFlag = playCharLayerFromApi(characterId);
|
||||
/* เหมือนเดิม: มีเลเยอร์แยกไฟล์เมื่อ API บอก hasLayerFiles หรือ probe เจอ — gen สีทำบน canvas จาก *_layer_* เท่านั้น */
|
||||
const charHasLayerFiles = apiFlag === true
|
||||
|| (apiFlag == null && playCharAnyDirectionLayered(characterId));
|
||||
/* ct3: ไม่ใช้ face จาก down บนทิศอื่น (แก้ตาหลังหัว) */
|
||||
const cacheKey = [characterId, dir, frameIndex, tint.head, tint.hair, tint.body, 'ct3'].join('|');
|
||||
const hit = playLayerCompositeCache.get(cacheKey);
|
||||
if (hit) return hit;
|
||||
const cacheKeyWalk = [characterId, dirn, frameIndexWalk, tint.head, tint.hair, tint.body, 'ct3'].join('|');
|
||||
|
||||
if (charHasLayerFiles) {
|
||||
const pack = tryComposePlayLayersFromFiles(rawImg, characterId, dir, frameIndex, tint);
|
||||
if (!isWalking) {
|
||||
const cacheKeyIdle = [characterId, dirn, idlePhase, tint.head, tint.hair, tint.body, 'ct3', 'idleL'].join('|');
|
||||
const hitI = playLayerCompositeCache.get(cacheKeyIdle);
|
||||
if (hitI) return hitI;
|
||||
const packI = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, idlePhase, tint, { idle: true });
|
||||
if (packI && packI.canvas) {
|
||||
if (!packI.skipCache) playLayerCompositeCache.set(cacheKeyIdle, packI.canvas);
|
||||
return packI.canvas;
|
||||
}
|
||||
}
|
||||
const hit = playLayerCompositeCache.get(cacheKeyWalk);
|
||||
if (hit) return hit;
|
||||
const pack = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, frameIndexWalk, tint, { idle: false });
|
||||
if (pack && pack.canvas) {
|
||||
if (!pack.skipCache) playLayerCompositeCache.set(cacheKey, pack.canvas);
|
||||
if (!pack.skipCache) playLayerCompositeCache.set(cacheKeyWalk, pack.canvas);
|
||||
return pack.canvas;
|
||||
}
|
||||
return rawImg;
|
||||
@@ -479,6 +556,15 @@
|
||||
function getCharacterFrame(id, direction, now, isWalking) {
|
||||
if (!id) return null;
|
||||
const dir = direction || 'down';
|
||||
const t = typeof now === 'number' ? now : Date.now();
|
||||
if (!isWalking) {
|
||||
const idleAnim = ensureCharacterIdleAnim(id, dir);
|
||||
const idlePhase = idleAnimPhaseIndex(t);
|
||||
const idleFi = pickLoadedWalkFrameIndex(idleAnim, idlePhase);
|
||||
if (idleFi >= 0) return idleAnim.frames[idleFi];
|
||||
const idfb = idleAnim.fallback;
|
||||
if (idfb && idfb.complete && idfb.naturalWidth) return idfb;
|
||||
}
|
||||
const key = id + '_' + dir;
|
||||
let anim = characterAnimations[key];
|
||||
if (!anim) {
|
||||
|
||||
@@ -247,6 +247,7 @@
|
||||
}
|
||||
const defaultAvatarImg = createDefaultAvatarImg();
|
||||
const characterAnimations = {};
|
||||
const characterIdleAnimations = {};
|
||||
const CHARACTER_ANIM_FRAMES = 4;
|
||||
const CHARACTER_ANIM_FRAME_MS = 200;
|
||||
|
||||
@@ -265,6 +266,31 @@
|
||||
return -1;
|
||||
}
|
||||
|
||||
function ensureCharacterIdleAnim(id, dir) {
|
||||
const d = dir || 'down';
|
||||
const key = id + '_' + d + '_idle';
|
||||
var anim = characterIdleAnimations[key];
|
||||
if (!anim) {
|
||||
anim = { frames: [], fallback: null };
|
||||
characterIdleAnimations[key] = anim;
|
||||
var enc = encodeURIComponent(id);
|
||||
var base = BASE + '/img/characters/' + enc + '_' + d;
|
||||
for (var ii = 0; ii < CHARACTER_ANIM_FRAMES; ii++) {
|
||||
var im = new Image();
|
||||
im.src = base + '_idle_' + ii + '.png';
|
||||
anim.frames.push(im);
|
||||
}
|
||||
anim.fallback = new Image();
|
||||
anim.fallback.src = base + '_idle.png';
|
||||
}
|
||||
return anim;
|
||||
}
|
||||
|
||||
function idleAnimPhaseIndex(now) {
|
||||
var t = typeof now === 'number' ? now : Date.now();
|
||||
return Math.floor(t / CHARACTER_ANIM_FRAME_MS) % CHARACTER_ANIM_FRAMES;
|
||||
}
|
||||
|
||||
function getCharacterImg(id, direction) {
|
||||
if (!id) return null;
|
||||
const key = id + '_' + (direction || 'down');
|
||||
@@ -278,6 +304,15 @@
|
||||
function getCharacterFrame(id, direction, now, isWalking) {
|
||||
if (!id) return null;
|
||||
const dir = direction || 'down';
|
||||
const t = typeof now === 'number' ? now : Date.now();
|
||||
if (!isWalking) {
|
||||
const idleAnim = ensureCharacterIdleAnim(id, dir);
|
||||
const idlePhase = idleAnimPhaseIndex(t);
|
||||
const idleFi = pickLoadedWalkFrameIndex(idleAnim, idlePhase);
|
||||
if (idleFi >= 0) return idleAnim.frames[idleFi];
|
||||
const idfb = idleAnim.fallback;
|
||||
if (idfb && idfb.complete && idfb.naturalWidth) return idfb;
|
||||
}
|
||||
const key = id + '_' + dir;
|
||||
let anim = characterAnimations[key];
|
||||
if (!anim) {
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
}
|
||||
const defaultAvatarImg = createDefaultAvatarImg();
|
||||
const characterAnimations = {};
|
||||
const characterIdleAnimations = {};
|
||||
const CHARACTER_ANIM_FRAMES = 4;
|
||||
const CHARACTER_ANIM_FRAME_MS = 200;
|
||||
|
||||
@@ -158,6 +159,42 @@
|
||||
return -1;
|
||||
}
|
||||
|
||||
function ensureCharacterIdleAnim(id, dir) {
|
||||
const d = dir || 'down';
|
||||
const key = id + '_' + d + '_idle';
|
||||
let anim = characterIdleAnimations[key];
|
||||
if (!anim) {
|
||||
anim = { frames: [], fallback: null };
|
||||
characterIdleAnimations[key] = anim;
|
||||
const enc = encodeURIComponent(id);
|
||||
for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) {
|
||||
const img = new Image();
|
||||
img.src = BASE + '/img/characters/' + enc + '_' + d + '_idle_' + i + '.png';
|
||||
anim.frames.push(img);
|
||||
}
|
||||
const fb = new Image();
|
||||
fb.src = BASE + '/img/characters/' + enc + '_' + d + '_idle.png';
|
||||
anim.fallback = fb;
|
||||
}
|
||||
return anim;
|
||||
}
|
||||
|
||||
function idleAnimPhaseIndex(now) {
|
||||
const t = typeof now === 'number' ? now : Date.now();
|
||||
return Math.floor(t / CHARACTER_ANIM_FRAME_MS) % CHARACTER_ANIM_FRAMES;
|
||||
}
|
||||
|
||||
function characterIdleSpritesVisible(id, dir) {
|
||||
if (!id) return false;
|
||||
const anim = ensureCharacterIdleAnim(id, dir || 'down');
|
||||
if (anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return true;
|
||||
for (let i = 0; i < anim.frames.length; i++) {
|
||||
const f = anim.frames[i];
|
||||
if (f && f.complete && f.naturalWidth) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** สุ่มสีตามเลเยอร์เดียวกับ character.html: bodyColor / hairColor / headColor (ลำดับซ้อนเหมือน composeLayeredFrame) */
|
||||
const PLAY_LAYER_ORDER = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face'];
|
||||
const PLAY_LAYER_TINT_KEY = { bodyColor: 'body', headColor: 'head', hairColor: 'hair' };
|
||||
@@ -320,6 +357,19 @@
|
||||
return out;
|
||||
}
|
||||
|
||||
function layerUrlCandidatesIdle(id, dir, layerName, frameIndex) {
|
||||
const enc = encodeURIComponent(id);
|
||||
const base = BASE + '/img/characters/' + enc + '_' + dir + '_idle';
|
||||
const out = [];
|
||||
function add(u) {
|
||||
if (out.indexOf(u) === -1) out.push(u);
|
||||
}
|
||||
add(base + '_' + frameIndex + '_layer_' + layerName + '.png');
|
||||
if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png');
|
||||
add(base + '_layer_' + layerName + '.png');
|
||||
return out;
|
||||
}
|
||||
|
||||
function shadowUrlCandidates(id, dir, frameIndex) {
|
||||
const c = layerUrlCandidates(id, dir, 'shadow', frameIndex);
|
||||
const def = BASE + '/img/default-shadow-' + dir + '.png';
|
||||
@@ -327,6 +377,13 @@
|
||||
return c;
|
||||
}
|
||||
|
||||
function shadowUrlCandidatesIdle(id, dir, frameIndex) {
|
||||
const c = layerUrlCandidatesIdle(id, dir, 'shadow', frameIndex);
|
||||
const def = BASE + '/img/default-shadow-' + dir + '.png';
|
||||
if (c.indexOf(def) === -1) c.push(def);
|
||||
return c;
|
||||
}
|
||||
|
||||
/** ลองทีละ URL — อย่า set src พร้อมกันหลายรูป (เดิมทำให้ legacy `id_dir_layer_x` โดน 404 ทุกเลเยอร์ทุกทิศทั้งที่มีแค่ `id_dir_0_layer_x`) */
|
||||
function resolvePlayLayerImage(urls) {
|
||||
for (let i = 0; i < urls.length; i++) {
|
||||
@@ -400,7 +457,8 @@
|
||||
return fi >= 0 ? fi : 0;
|
||||
}
|
||||
|
||||
function tryComposePlayLayersFromFiles(rawImg, id, dir, frameIndex, tint) {
|
||||
function tryComposePlayLayersFromFiles(rawImg, id, dir, frameIndex, tint, opts) {
|
||||
const useIdle = opts && opts.idle;
|
||||
if (!rawImg || !rawImg.naturalWidth || !rawImg.naturalHeight) return null;
|
||||
const w = rawImg.naturalWidth, h = rawImg.naturalHeight;
|
||||
const c = document.createElement('canvas');
|
||||
@@ -415,8 +473,8 @@
|
||||
if (diskLayers && layerName !== 'shadow' && !diskLayers.has(layerName)) continue;
|
||||
/* ห้าม fallback face จากทิศ down เมื่อเป็น up/left/right — จะได้ตาอยู่หลังหัวเพราะวางหน้าหน้าบนร่างหันหลัง */
|
||||
const urls = layerName === 'shadow'
|
||||
? shadowUrlCandidates(id, dir, frameIndex)
|
||||
: layerUrlCandidates(id, dir, layerName, frameIndex);
|
||||
? (useIdle ? shadowUrlCandidatesIdle(id, dir, frameIndex) : shadowUrlCandidates(id, dir, frameIndex))
|
||||
: (useIdle ? layerUrlCandidatesIdle(id, dir, layerName, frameIndex) : layerUrlCandidates(id, dir, layerName, frameIndex));
|
||||
const r = resolvePlayLayerImage(urls);
|
||||
/* เงาโหลดช้า/404 บ่อย — อย่าให้บล็อกทั้งคอมโพส (ไม่งั้นตกไป fallback แถบแนวตั้งแทนเลเยอร์จริง) */
|
||||
if (r.status === 'pending' && layerName === 'shadow') {
|
||||
@@ -438,23 +496,39 @@
|
||||
if (!tint || !characterId || !rawImg) return rawImg;
|
||||
ensurePlayCharLayerListFetch();
|
||||
ensurePlayLayerProbesAllDirections(characterId);
|
||||
const frameIndex = getCharacterAnimFrameIndex(characterId, dir, timeMs, isWalking);
|
||||
const dirn = dir || 'down';
|
||||
const frameIndexWalk = getCharacterAnimFrameIndex(characterId, dirn, timeMs, isWalking);
|
||||
const idlePhase = idleAnimPhaseIndex(typeof timeMs === 'number' ? timeMs : Date.now());
|
||||
|
||||
if (playCharLayerDiscoveryPending(characterId)) return rawImg;
|
||||
|
||||
if (!isWalking && characterIdleSpritesVisible(characterId, dirn)) {
|
||||
return rawImg;
|
||||
}
|
||||
|
||||
const apiFlag = playCharLayerFromApi(characterId);
|
||||
/* เหมือนเดิม: มีเลเยอร์แยกไฟล์เมื่อ API บอก hasLayerFiles หรือ probe เจอ — gen สีทำบน canvas จาก *_layer_* เท่านั้น */
|
||||
const charHasLayerFiles = apiFlag === true
|
||||
|| (apiFlag == null && playCharAnyDirectionLayered(characterId));
|
||||
/* ct3: ไม่ใช้ face จาก down บนทิศอื่น (แก้ตาหลังหัว) */
|
||||
const cacheKey = [characterId, dir, frameIndex, tint.head, tint.hair, tint.body, 'ct3'].join('|');
|
||||
const hit = playLayerCompositeCache.get(cacheKey);
|
||||
if (hit) return hit;
|
||||
const cacheKeyWalk = [characterId, dirn, frameIndexWalk, tint.head, tint.hair, tint.body, 'ct3'].join('|');
|
||||
|
||||
if (charHasLayerFiles) {
|
||||
const pack = tryComposePlayLayersFromFiles(rawImg, characterId, dir, frameIndex, tint);
|
||||
if (!isWalking) {
|
||||
const cacheKeyIdle = [characterId, dirn, idlePhase, tint.head, tint.hair, tint.body, 'ct3', 'idleL'].join('|');
|
||||
const hitI = playLayerCompositeCache.get(cacheKeyIdle);
|
||||
if (hitI) return hitI;
|
||||
const packI = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, idlePhase, tint, { idle: true });
|
||||
if (packI && packI.canvas) {
|
||||
if (!packI.skipCache) playLayerCompositeCache.set(cacheKeyIdle, packI.canvas);
|
||||
return packI.canvas;
|
||||
}
|
||||
}
|
||||
const hit = playLayerCompositeCache.get(cacheKeyWalk);
|
||||
if (hit) return hit;
|
||||
const pack = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, frameIndexWalk, tint, { idle: false });
|
||||
if (pack && pack.canvas) {
|
||||
if (!pack.skipCache) playLayerCompositeCache.set(cacheKey, pack.canvas);
|
||||
if (!pack.skipCache) playLayerCompositeCache.set(cacheKeyWalk, pack.canvas);
|
||||
return pack.canvas;
|
||||
}
|
||||
return rawImg;
|
||||
@@ -479,6 +553,15 @@
|
||||
function getCharacterFrame(id, direction, now, isWalking) {
|
||||
if (!id) return null;
|
||||
const dir = direction || 'down';
|
||||
const t = typeof now === 'number' ? now : Date.now();
|
||||
if (!isWalking) {
|
||||
const idleAnim = ensureCharacterIdleAnim(id, dir);
|
||||
const idlePhase = idleAnimPhaseIndex(t);
|
||||
const idleFi = pickLoadedWalkFrameIndex(idleAnim, idlePhase);
|
||||
if (idleFi >= 0) return idleAnim.frames[idleFi];
|
||||
const idfb = idleAnim.fallback;
|
||||
if (idfb && idfb.complete && idfb.naturalWidth) return idfb;
|
||||
}
|
||||
const key = id + '_' + dir;
|
||||
let anim = characterAnimations[key];
|
||||
if (!anim) {
|
||||
|
||||
@@ -57,7 +57,7 @@ const CHAR_MANIFEST_DIRS = ['up', 'down', 'left', 'right'];
|
||||
|
||||
/** รายการไฟล์เลเยอร์ต่อทิศ/เฟรม — ให้หน้า character แก้ไขโหลด preview ได้ */
|
||||
function buildCharacterLayerManifest(id, files) {
|
||||
if (!id || typeof id !== 'string') return { ok: false, error: 'bad id', byDir: {} };
|
||||
if (!id || typeof id !== 'string') return { ok: false, error: 'bad id', byDir: {}, byDirIdle: {} };
|
||||
const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
const ext = /\.(png|jpg|jpeg|gif|webp)$/i;
|
||||
const layerAlt = CHAR_LAYER_MANIFEST_KEYS.join('|');
|
||||
@@ -92,7 +92,38 @@ function buildCharacterLayerManifest(id, files) {
|
||||
if (Object.keys(frame0).length) byDir[dir] = { multi: false, frames: [frame0] };
|
||||
}
|
||||
}
|
||||
return { ok: true, id, byDir };
|
||||
const byDirIdle = {};
|
||||
for (const dir of CHAR_MANIFEST_DIRS) {
|
||||
const multiIdleRe = new RegExp('^' + esc + '_' + dir + '_idle_(\\d+)_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i');
|
||||
const singleIdleRe = new RegExp('^' + esc + '_' + dir + '_idle_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i');
|
||||
const multiIdleHits = files.filter((f) => multiIdleRe.test(f));
|
||||
if (multiIdleHits.length) {
|
||||
let maxFi = -1;
|
||||
const perFrame = new Map();
|
||||
multiIdleHits.forEach((f) => {
|
||||
const m = f.match(multiIdleRe);
|
||||
if (!m) return;
|
||||
const fi = parseInt(m[1], 10);
|
||||
if (!Number.isFinite(fi) || fi < 0 || fi > 200) return;
|
||||
const layer = m[2];
|
||||
if (fi > maxFi) maxFi = fi;
|
||||
if (!perFrame.has(fi)) perFrame.set(fi, {});
|
||||
perFrame.get(fi)[layer] = f;
|
||||
});
|
||||
const frames = [];
|
||||
for (let i = 0; i <= maxFi; i++) frames.push(perFrame.get(i) || {});
|
||||
byDirIdle[dir] = { multi: true, frames };
|
||||
} else {
|
||||
const frame0 = {};
|
||||
files.forEach((f) => {
|
||||
if (!f.startsWith(id + '_' + dir + '_idle_layer_') || !ext.test(f)) return;
|
||||
const m = f.match(singleIdleRe);
|
||||
if (m) frame0[m[1]] = f;
|
||||
});
|
||||
if (Object.keys(frame0).length) byDirIdle[dir] = { multi: false, frames: [frame0] };
|
||||
}
|
||||
}
|
||||
return { ok: true, id, byDir, byDirIdle };
|
||||
}
|
||||
|
||||
const GAUNTLET_ASSETS_DIR = path.join(__dirname, 'data', 'gauntlet-assets');
|
||||
@@ -1822,6 +1853,10 @@ const server = http.createServer((req, res) => {
|
||||
if (m) ids.add(m[1]);
|
||||
m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i);
|
||||
if (m) ids.add(m[1]);
|
||||
m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i);
|
||||
if (m) ids.add(m[1]);
|
||||
m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i);
|
||||
if (m) ids.add(m[1]);
|
||||
});
|
||||
const idArr = [...ids];
|
||||
const oldestMsById = {};
|
||||
@@ -1851,7 +1886,12 @@ const server = http.createServer((req, res) => {
|
||||
if (hasLayerFiles) {
|
||||
files.forEach((f) => {
|
||||
if (!f.startsWith(prefix) || !ext.test(f)) return;
|
||||
const m = f.match(/_(?:up|down|left|right)(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i);
|
||||
let m = f.match(/_(?:up|down|left|right)_idle(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i);
|
||||
if (m) {
|
||||
layerNameSet.add(m[1]);
|
||||
return;
|
||||
}
|
||||
m = f.match(/_(?:up|down|left|right)(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i);
|
||||
if (m) layerNameSet.add(m[1]);
|
||||
});
|
||||
}
|
||||
@@ -1873,7 +1913,7 @@ const server = http.createServer((req, res) => {
|
||||
const id = decodeURIComponent(lm[1] || '').replace(/[^a-z0-9_-]/gi, '');
|
||||
if (!id) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง', byDir: {} }));
|
||||
return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง', byDir: {}, byDirIdle: {} }));
|
||||
}
|
||||
try {
|
||||
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
|
||||
@@ -1883,7 +1923,7 @@ const server = http.createServer((req, res) => {
|
||||
return res.end(JSON.stringify(manifest));
|
||||
} catch (e) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
return res.end(JSON.stringify({ ok: false, error: e.message || 'manifest fail', byDir: {} }));
|
||||
return res.end(JSON.stringify({ ok: false, error: e.message || 'manifest fail', byDir: {}, byDirIdle: {} }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1903,10 +1943,12 @@ const server = http.createServer((req, res) => {
|
||||
const files = fs.readdirSync(CHARACTERS_DIR);
|
||||
const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
const re = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i');
|
||||
const idleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i');
|
||||
const layerRe = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i');
|
||||
const layerIdleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i');
|
||||
let removed = 0;
|
||||
files.forEach(f => {
|
||||
if (re.test(f) || layerRe.test(f)) {
|
||||
if (re.test(f) || idleRe.test(f) || layerRe.test(f) || layerIdleRe.test(f)) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(CHARACTERS_DIR, f));
|
||||
removed++;
|
||||
@@ -1937,7 +1979,7 @@ const server = http.createServer((req, res) => {
|
||||
const id = (d.name || '').trim().replace(/[^a-z0-9_-]/gi, '') || 'char-' + Date.now();
|
||||
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
|
||||
const ALLOWED_LAYER = { shadow: 1, bodyColor: 1, bodyStroke: 1, headColor: 1, headStroke: 1, hairColor: 1, hairStroke: 1, face: 1 };
|
||||
let written = 0;
|
||||
let walkWritten = 0;
|
||||
for (const dir of dirs) {
|
||||
let data = d[dir];
|
||||
if (!data) continue;
|
||||
@@ -1964,10 +2006,11 @@ const server = http.createServer((req, res) => {
|
||||
const baseName = id + '_' + dir + '.png';
|
||||
fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf);
|
||||
}
|
||||
written++;
|
||||
walkWritten++;
|
||||
frameIndex++;
|
||||
}
|
||||
}
|
||||
let layerWritten = 0;
|
||||
// ไฟล์เลเยอร์แยก (ให้หน้า play ย้อม bodyColor / hairColor / headColor ตามส่วน — ตรงกับ character.html)
|
||||
const lf = d.layerFrames;
|
||||
if (lf && typeof lf === 'object') {
|
||||
@@ -1996,13 +2039,78 @@ const server = http.createServer((req, res) => {
|
||||
? (id + '_' + dir + '_' + frameIndex + '_layer_' + layerName + '.png')
|
||||
: (id + '_' + dir + '_layer_' + layerName + '.png');
|
||||
fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf);
|
||||
layerWritten++;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (written === 0) {
|
||||
const lfIdle = d.layerFramesIdle;
|
||||
if (lfIdle && typeof lfIdle === 'object') {
|
||||
for (const dir of dirs) {
|
||||
const arr = lfIdle[dir];
|
||||
if (!Array.isArray(arr)) continue;
|
||||
const multi = arr.length > 1;
|
||||
arr.forEach((frameObj, frameIndex) => {
|
||||
if (!frameObj || typeof frameObj !== 'object') return;
|
||||
for (const layerName of Object.keys(frameObj)) {
|
||||
if (!ALLOWED_LAYER[layerName]) continue;
|
||||
const dataUrl = frameObj[layerName];
|
||||
if (!dataUrl || typeof dataUrl !== 'string') continue;
|
||||
const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
|
||||
if (!m) continue;
|
||||
const b64 = m[1].replace(/\s/g, '');
|
||||
if (!b64.length) continue;
|
||||
let buf;
|
||||
try {
|
||||
buf = Buffer.from(b64, 'base64');
|
||||
} catch (err) {
|
||||
continue;
|
||||
}
|
||||
if (buf.length === 0) continue;
|
||||
const fnameLayer = multi
|
||||
? (id + '_' + dir + '_idle_' + frameIndex + '_layer_' + layerName + '.png')
|
||||
: (id + '_' + dir + '_idle_layer_' + layerName + '.png');
|
||||
fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf);
|
||||
layerWritten++;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
const idleObj = d.idle && typeof d.idle === 'object' ? d.idle : null;
|
||||
if (idleObj) {
|
||||
for (const dir of dirs) {
|
||||
let data = idleObj[dir];
|
||||
if (!data) continue;
|
||||
const list = Array.isArray(data) ? data : [data];
|
||||
let frameIndex = 0;
|
||||
for (const dataUrl of list) {
|
||||
if (!dataUrl || typeof dataUrl !== 'string') continue;
|
||||
const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
|
||||
if (!m) continue;
|
||||
const b64 = m[1].replace(/\s/g, '');
|
||||
if (!b64.length) continue;
|
||||
let buf;
|
||||
try {
|
||||
buf = Buffer.from(b64, 'base64');
|
||||
} catch (err) {
|
||||
continue;
|
||||
}
|
||||
if (buf.length === 0) continue;
|
||||
const fnameFrame = list.length > 1
|
||||
? (id + '_' + dir + '_idle_' + frameIndex + '.png')
|
||||
: (id + '_' + dir + '_idle.png');
|
||||
fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf);
|
||||
if (list.length > 1 && frameIndex === 0) {
|
||||
const baseName = id + '_' + dir + '_idle.png';
|
||||
fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf);
|
||||
}
|
||||
frameIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (walkWritten === 0 && layerWritten === 0) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ ok: false, error: 'ไม่มีรูปที่อัปโหลดได้ (เลือกรูป png/jpg)' }));
|
||||
return res.end(JSON.stringify({ ok: false, error: 'ไม่มีรูปที่อัปโหลดได้ (ต้องมีรูปเดินหรือเลเยอร์อย่างน้อยหนึ่งทิศ)' }));
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, characterId: id }));
|
||||
|
||||