diff --git a/www/html/Game/data/characters/char-1777017632279_down.png b/www/html/Game/data/characters/char-1777017632279_down.png index 6b81884..d8e0ac5 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_down.png and b/www/html/Game/data/characters/char-1777017632279_down.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_down_0.png b/www/html/Game/data/characters/char-1777017632279_down_0.png index 6b81884..d8e0ac5 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_down_0.png and b/www/html/Game/data/characters/char-1777017632279_down_0.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_down_1.png b/www/html/Game/data/characters/char-1777017632279_down_1.png index 979bd86..ae6e21d 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_down_1.png and b/www/html/Game/data/characters/char-1777017632279_down_1.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_down_2.png b/www/html/Game/data/characters/char-1777017632279_down_2.png index 929dc2c..dce11e5 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_down_2.png and b/www/html/Game/data/characters/char-1777017632279_down_2.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_down_3.png b/www/html/Game/data/characters/char-1777017632279_down_3.png index 1a88202..3a0e2be 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_down_3.png and b/www/html/Game/data/characters/char-1777017632279_down_3.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_down_idle.png b/www/html/Game/data/characters/char-1777017632279_down_idle.png new file mode 100644 index 0000000..db5948f Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_down_idle.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_down_idle_layer_bodyColor.png b/www/html/Game/data/characters/char-1777017632279_down_idle_layer_bodyColor.png new file mode 100644 index 0000000..1ba96aa Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_down_idle_layer_bodyColor.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_down_idle_layer_bodyStroke.png b/www/html/Game/data/characters/char-1777017632279_down_idle_layer_bodyStroke.png new file mode 100644 index 0000000..12cd709 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_down_idle_layer_bodyStroke.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_down_idle_layer_face.png b/www/html/Game/data/characters/char-1777017632279_down_idle_layer_face.png new file mode 100644 index 0000000..eeefe59 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_down_idle_layer_face.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_down_idle_layer_hairColor.png b/www/html/Game/data/characters/char-1777017632279_down_idle_layer_hairColor.png new file mode 100644 index 0000000..dded950 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_down_idle_layer_hairColor.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_down_idle_layer_hairStroke.png b/www/html/Game/data/characters/char-1777017632279_down_idle_layer_hairStroke.png new file mode 100644 index 0000000..d33b4dd Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_down_idle_layer_hairStroke.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_down_idle_layer_headColor.png b/www/html/Game/data/characters/char-1777017632279_down_idle_layer_headColor.png new file mode 100644 index 0000000..ac6f0be Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_down_idle_layer_headColor.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_down_idle_layer_headStroke.png b/www/html/Game/data/characters/char-1777017632279_down_idle_layer_headStroke.png new file mode 100644 index 0000000..9c8f736 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_down_idle_layer_headStroke.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_left.png b/www/html/Game/data/characters/char-1777017632279_left.png index effaddd..52a6dbe 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_left.png and b/www/html/Game/data/characters/char-1777017632279_left.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_left_0.png b/www/html/Game/data/characters/char-1777017632279_left_0.png index effaddd..52a6dbe 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_left_0.png and b/www/html/Game/data/characters/char-1777017632279_left_0.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_left_1.png b/www/html/Game/data/characters/char-1777017632279_left_1.png index aaf4d0e..1dfa234 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_left_1.png and b/www/html/Game/data/characters/char-1777017632279_left_1.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_left_2.png b/www/html/Game/data/characters/char-1777017632279_left_2.png index 465aa49..1208155 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_left_2.png and b/www/html/Game/data/characters/char-1777017632279_left_2.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_left_3.png b/www/html/Game/data/characters/char-1777017632279_left_3.png index d906252..0603e58 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_left_3.png and b/www/html/Game/data/characters/char-1777017632279_left_3.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_left_idle.png b/www/html/Game/data/characters/char-1777017632279_left_idle.png new file mode 100644 index 0000000..6b2cc6b Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_left_idle.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_left_idle_layer_bodyColor.png b/www/html/Game/data/characters/char-1777017632279_left_idle_layer_bodyColor.png new file mode 100644 index 0000000..d68871c Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_left_idle_layer_bodyColor.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_left_idle_layer_bodyStroke.png b/www/html/Game/data/characters/char-1777017632279_left_idle_layer_bodyStroke.png new file mode 100644 index 0000000..08eb049 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_left_idle_layer_bodyStroke.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_left_idle_layer_face.png b/www/html/Game/data/characters/char-1777017632279_left_idle_layer_face.png new file mode 100644 index 0000000..b840af9 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_left_idle_layer_face.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_left_idle_layer_hairColor.png b/www/html/Game/data/characters/char-1777017632279_left_idle_layer_hairColor.png new file mode 100644 index 0000000..c34dbf9 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_left_idle_layer_hairColor.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_left_idle_layer_hairStroke.png b/www/html/Game/data/characters/char-1777017632279_left_idle_layer_hairStroke.png new file mode 100644 index 0000000..bd82da5 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_left_idle_layer_hairStroke.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_left_idle_layer_headColor.png b/www/html/Game/data/characters/char-1777017632279_left_idle_layer_headColor.png new file mode 100644 index 0000000..2b6c475 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_left_idle_layer_headColor.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_left_idle_layer_headStroke.png b/www/html/Game/data/characters/char-1777017632279_left_idle_layer_headStroke.png new file mode 100644 index 0000000..16ee4aa Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_left_idle_layer_headStroke.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_left_idle_layer_shadow.png b/www/html/Game/data/characters/char-1777017632279_left_idle_layer_shadow.png new file mode 100644 index 0000000..f1685b1 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_left_idle_layer_shadow.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_right.png b/www/html/Game/data/characters/char-1777017632279_right.png index 5af05d8..89ad0ad 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_right.png and b/www/html/Game/data/characters/char-1777017632279_right.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_right_0.png b/www/html/Game/data/characters/char-1777017632279_right_0.png index 5af05d8..89ad0ad 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_right_0.png and b/www/html/Game/data/characters/char-1777017632279_right_0.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_right_1.png b/www/html/Game/data/characters/char-1777017632279_right_1.png index e62bb73..768c3cd 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_right_1.png and b/www/html/Game/data/characters/char-1777017632279_right_1.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_right_2.png b/www/html/Game/data/characters/char-1777017632279_right_2.png index e019c47..90b8bf6 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_right_2.png and b/www/html/Game/data/characters/char-1777017632279_right_2.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_right_3.png b/www/html/Game/data/characters/char-1777017632279_right_3.png index e1a5d26..205ffd8 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_right_3.png and b/www/html/Game/data/characters/char-1777017632279_right_3.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_right_idle.png b/www/html/Game/data/characters/char-1777017632279_right_idle.png new file mode 100644 index 0000000..1885ae1 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_right_idle.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_right_idle_layer_bodyColor.png b/www/html/Game/data/characters/char-1777017632279_right_idle_layer_bodyColor.png new file mode 100644 index 0000000..06f0d32 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_right_idle_layer_bodyColor.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_right_idle_layer_bodyStroke.png b/www/html/Game/data/characters/char-1777017632279_right_idle_layer_bodyStroke.png new file mode 100644 index 0000000..7efd0f8 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_right_idle_layer_bodyStroke.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_right_idle_layer_face.png b/www/html/Game/data/characters/char-1777017632279_right_idle_layer_face.png new file mode 100644 index 0000000..a575deb Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_right_idle_layer_face.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_right_idle_layer_hairColor.png b/www/html/Game/data/characters/char-1777017632279_right_idle_layer_hairColor.png new file mode 100644 index 0000000..5549064 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_right_idle_layer_hairColor.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_right_idle_layer_hairStroke.png b/www/html/Game/data/characters/char-1777017632279_right_idle_layer_hairStroke.png new file mode 100644 index 0000000..1764b1d Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_right_idle_layer_hairStroke.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_right_idle_layer_headColor.png b/www/html/Game/data/characters/char-1777017632279_right_idle_layer_headColor.png new file mode 100644 index 0000000..ce85f12 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_right_idle_layer_headColor.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_right_idle_layer_headStroke.png b/www/html/Game/data/characters/char-1777017632279_right_idle_layer_headStroke.png new file mode 100644 index 0000000..1d8b929 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_right_idle_layer_headStroke.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_right_idle_layer_shadow.png b/www/html/Game/data/characters/char-1777017632279_right_idle_layer_shadow.png new file mode 100644 index 0000000..c0c5384 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_right_idle_layer_shadow.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_up.png b/www/html/Game/data/characters/char-1777017632279_up.png index b8b3499..e1f5d0e 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_up.png and b/www/html/Game/data/characters/char-1777017632279_up.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_up_0.png b/www/html/Game/data/characters/char-1777017632279_up_0.png index b8b3499..e1f5d0e 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_up_0.png and b/www/html/Game/data/characters/char-1777017632279_up_0.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_up_1.png b/www/html/Game/data/characters/char-1777017632279_up_1.png index 89642c4..7d374b1 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_up_1.png and b/www/html/Game/data/characters/char-1777017632279_up_1.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_up_2.png b/www/html/Game/data/characters/char-1777017632279_up_2.png index 62dbead..2dfa07b 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_up_2.png and b/www/html/Game/data/characters/char-1777017632279_up_2.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_up_3.png b/www/html/Game/data/characters/char-1777017632279_up_3.png index 27b1859..6f3ed66 100644 Binary files a/www/html/Game/data/characters/char-1777017632279_up_3.png and b/www/html/Game/data/characters/char-1777017632279_up_3.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_up_idle.png b/www/html/Game/data/characters/char-1777017632279_up_idle.png new file mode 100644 index 0000000..f6df122 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_up_idle.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_up_idle_layer_bodyColor.png b/www/html/Game/data/characters/char-1777017632279_up_idle_layer_bodyColor.png new file mode 100644 index 0000000..87852c6 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_up_idle_layer_bodyColor.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_up_idle_layer_bodyStroke.png b/www/html/Game/data/characters/char-1777017632279_up_idle_layer_bodyStroke.png new file mode 100644 index 0000000..8b1d05e Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_up_idle_layer_bodyStroke.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_up_idle_layer_face.png b/www/html/Game/data/characters/char-1777017632279_up_idle_layer_face.png new file mode 100644 index 0000000..7ae4407 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_up_idle_layer_face.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_up_idle_layer_hairColor.png b/www/html/Game/data/characters/char-1777017632279_up_idle_layer_hairColor.png new file mode 100644 index 0000000..198c784 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_up_idle_layer_hairColor.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_up_idle_layer_hairStroke.png b/www/html/Game/data/characters/char-1777017632279_up_idle_layer_hairStroke.png new file mode 100644 index 0000000..37857aa Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_up_idle_layer_hairStroke.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_up_idle_layer_headColor.png b/www/html/Game/data/characters/char-1777017632279_up_idle_layer_headColor.png new file mode 100644 index 0000000..01224f4 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_up_idle_layer_headColor.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_up_idle_layer_headStroke.png b/www/html/Game/data/characters/char-1777017632279_up_idle_layer_headStroke.png new file mode 100644 index 0000000..c6d7323 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_up_idle_layer_headStroke.png differ diff --git a/www/html/Game/data/characters/char-1777017632279_up_idle_layer_shadow.png b/www/html/Game/data/characters/char-1777017632279_up_idle_layer_shadow.png new file mode 100644 index 0000000..1e34645 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_up_idle_layer_shadow.png differ diff --git a/www/html/Game/data/gauntlet-assets-meta.json b/www/html/Game/data/gauntlet-assets-meta.json new file mode 100644 index 0000000..0191828 --- /dev/null +++ b/www/html/Game/data/gauntlet-assets-meta.json @@ -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 + } +} \ No newline at end of file diff --git a/www/html/Game/data/gauntlet-assets/gauntlet-077c293962b8fe07.png b/www/html/Game/data/gauntlet-assets/gauntlet-077c293962b8fe07.png new file mode 100644 index 0000000..9f41af2 Binary files /dev/null and b/www/html/Game/data/gauntlet-assets/gauntlet-077c293962b8fe07.png differ diff --git a/www/html/Game/data/gauntlet-assets/gauntlet-2a75a958cd5e5e8a.png b/www/html/Game/data/gauntlet-assets/gauntlet-2a75a958cd5e5e8a.png new file mode 100644 index 0000000..d0a6b7b Binary files /dev/null and b/www/html/Game/data/gauntlet-assets/gauntlet-2a75a958cd5e5e8a.png differ diff --git a/www/html/Game/data/gauntlet-assets/gauntlet-67a585790f67fe67.png b/www/html/Game/data/gauntlet-assets/gauntlet-67a585790f67fe67.png new file mode 100644 index 0000000..366768c Binary files /dev/null and b/www/html/Game/data/gauntlet-assets/gauntlet-67a585790f67fe67.png differ diff --git a/www/html/Game/data/gauntlet-assets/gauntlet-6aa244ab734b12e4.png b/www/html/Game/data/gauntlet-assets/gauntlet-6aa244ab734b12e4.png new file mode 100644 index 0000000..b7b678c Binary files /dev/null and b/www/html/Game/data/gauntlet-assets/gauntlet-6aa244ab734b12e4.png differ diff --git a/www/html/Game/data/gauntlet-assets/gauntlet-c4d4482e2a60eac9.png b/www/html/Game/data/gauntlet-assets/gauntlet-c4d4482e2a60eac9.png new file mode 100644 index 0000000..7eed16e Binary files /dev/null and b/www/html/Game/data/gauntlet-assets/gauntlet-c4d4482e2a60eac9.png differ diff --git a/www/html/Game/public/character.html b/www/html/Game/public/character.html index e4533e3..e03b668 100644 --- a/www/html/Game/public/character.html +++ b/www/html/Game/public/character.html @@ -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 @@

อัปโหลดตัวละคร (บน · ล่าง · ซ้าย · ขวา)

-

เลือกรูปทั้ง 4 ทิศ แล้วใส่ชื่อ (ไม่บังคับ) กดอัปโหลด

+

เลือกรูปทั้ง 4 ทิศ แล้วใส่ชื่อ (ไม่บังคับ) กดอัปโหลด · รูป ยืนเฉย (idle) แยกเลเยอร์ต่อทิศอยู่ในตาราง แบบเลเยอร์ ด้านล่าง

@@ -123,85 +172,167 @@

-
+

อัปโหลดตัวละครแบบเลเยอร์ (Shadow / Body / Hair / Head / Face)

-

สำหรับผู้สร้างสกิน: แยกไฟล์ตามเลเยอร์ + ทิศ ระบบจะรวมเป็นเฟรมให้เองก่อนอัปโหลด (ไม่ใส่เงา = ใช้ค่าเริ่มต้น)

+

สำหรับผู้สร้างสกิน: แยกไฟล์ตามเลเยอร์ + ทิศ · ใช้เมนูด้านล่างสลับระหว่าง เดิน กับ ยืนเฉย (idle) — ถ้าไม่ใส่ idle เกมจะใช้เฟรมเดินเมื่อยืนนิ่ง

+ +
+ + +
+
-
- Shadow - - Body - - - Hair - - - Head - - - Face - +
+
+ Shadow + + Body + + + Hair + + + Head + + + Face + + +
+
+

เลเยอร์ยืนเฉย (idle) ทิศบน — โครงเดียวกับเดิน

+ Shadow + + Body + + + Hair + + + Head + + + Face + + +
-
-
- Shadow - - Body - - - Hair - - - Head - - - Face - +
+
+ Shadow + + Body + + + Hair + + + Head + + + Face + + +
+
+

เลเยอร์ยืนเฉย (idle) ทิศล่าง

+ Shadow + + Body + + + Hair + + + Head + + + Face + + +
-
-
- Shadow - - Body - - - Hair - - - Head - - - Face - +
+
+ Shadow + + Body + + + Hair + + + Head + + + Face + + +
+
+

เลเยอร์ยืนเฉย (idle) ทิศซ้าย

+ Shadow + + Body + + + Hair + + + Head + + + Face + + +
-
-
- Shadow - - Body - - - Hair - - - Head - - - Face - +
+
+ Shadow + + Body + + + Hair + + + Head + + + Face + + +
+
+

เลเยอร์ยืนเฉย (idle) ทิศขวา

+ Shadow + + Body + + + Hair + + + Head + + + Face + + +
-
@@ -217,7 +348,7 @@
- +
v —
diff --git a/www/html/Game/public/character.js b/www/html/Game/public/character.js index ee178c5..ad4c85c 100644 --- a/www/html/Game/public/character.js +++ b/www/html/Game/public/character.js @@ -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('ไม่มี'); 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'); + })(); })(); diff --git a/www/html/Game/public/js/character.js b/www/html/Game/public/js/character.js index 00ccb0f..ad4c85c 100644 --- a/www/html/Game/public/js/character.js +++ b/www/html/Game/public/js/character.js @@ -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('ไม่มี'); 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'); + })(); })(); diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js index 303f1e9..13b704f 100644 --- a/www/html/Game/public/js/play.js +++ b/www/html/Game/public/js/play.js @@ -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__idle__layer_.png หรือ id__idle_layer_.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) { diff --git a/www/html/Game/public/js/room-lobby.js b/www/html/Game/public/js/room-lobby.js index 17bc333..444fec8 100644 --- a/www/html/Game/public/js/room-lobby.js +++ b/www/html/Game/public/js/room-lobby.js @@ -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) { diff --git a/www/html/Game/public/play.js b/www/html/Game/public/play.js index 1cf52a4..ae7667f 100644 --- a/www/html/Game/public/play.js +++ b/www/html/Game/public/play.js @@ -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) { diff --git a/www/html/Game/server.js b/www/html/Game/server.js index 6430c89..ce48bab 100644 --- a/www/html/Game/server.js +++ b/www/html/Game/server.js @@ -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 }));