Merge pull request #91 from JamesLewisLiu/GWD

GITADORA PlayerBoard Customization Update
This commit is contained in:
Freddie W
2026-09-06 18:17:30 +08:00
committed by GitHub
7 changed files with 7900 additions and 32 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ import Logger from "../utils/logger"
import { isAsphyxiaDebugMode, isRivalEnabled, isSharedSongScoresEnabled } from "../utils/index";
import { SecretMusicEntry } from "../models/secretmusicentry";
import { CheckPlayerResponse, getCheckPlayerResponse } from "../models/Responses/checkplayerresponse";
import { getPlayerStickerResponse, PlayerStickerResponse } from "../models/Responses/playerstickerresponse";
import { getPlayerBoardResponse } from "../models/Responses/playerstickerresponse";
import { getSecretMusicResponse, SecretMusicResponse } from "../models/Responses/secretmusicresponse";
import { getSaveProfileResponse } from "../models/Responses/saveprofileresponse";
import { getDefaultBattleDataResponse } from "../models/Responses/battledataresponse";
@@ -292,14 +292,12 @@ export const getPlayer: EPR = async (info, data, send) => {
}));
}
const sticker: PlayerStickerResponse[] = getPlayerStickerResponse(name.card);
const playerboard = getPlayerBoardResponse(name);
const playinfo: PlayerPlayInfoResponse = getPlayerPlayInfoResponse(profile);
const playerData: any = {
playerboard: {
index: K.ITEM('s32', 1),
is_active: K.ITEM('bool', _.isArray(name.card) ? 1 : 0),
sticker,
...playerboard,
},
player_info: {
// The initial profile is player type 0. Each follow-up rival profile is

View File

@@ -15,7 +15,7 @@ import Logger from "../utils/logger"
import { isAsphyxiaDebugMode, isSharedSongScoresEnabled } from "../utils/index";
import { SecretMusicEntry } from "../models/secretmusicentry";
import { CheckPlayerResponse, getCheckPlayerResponse } from "../models/Responses/checkplayerresponse";
import { getPlayerStickerResponse, PlayerStickerResponse } from "../models/Responses/playerstickerresponse";
import { getPlayerBoardResponse } from "../models/Responses/playerstickerresponse";
import { getSecretMusicResponse, SecretMusicResponse } from "../models/Responses/secretmusicresponse";
import { getSaveProfileResponse } from "../models/Responses/saveprofileresponse";
import { getDefaultBattleDataResponse } from "../models/Responses/battledataresponse";
@@ -181,14 +181,12 @@ export const getPlayer: EPR = async (info, data, send) => {
}));
}
const sticker: PlayerStickerResponse[] = getPlayerStickerResponse(name.card);
const playerboard = getPlayerBoardResponse(name);
const playinfo: PlayerPlayInfoResponse = getPlayerPlayInfoResponse(profile);
const playerData: any = {
playerboard: {
index: K.ITEM('s32', 1),
is_active: K.ITEM('bool', _.isArray(name.card) ? 1 : 0),
sticker,
...playerboard,
},
player_info: {
player_type: K.ITEM('s8', 0),

View File

@@ -6,6 +6,7 @@ export const updatePlayerInfo = async (data: {
version: string;
name?: string;
title?: string;
playerboard?: string | PlayerInfo['playerboard'];
}) => {
if (data.refid == null) return;
@@ -21,6 +22,11 @@ export const updatePlayerInfo = async (data: {
update.title = data.title;
}
if (data.playerboard !== undefined) {
const playerboard = parsePlayerBoard(data.playerboard);
if (playerboard) update.playerboard = playerboard;
}
await DB.Update<PlayerInfo>(
data.refid,
{ collection: 'playerinfo', version: data.version },
@@ -28,6 +34,61 @@ export const updatePlayerInfo = async (data: {
);
};
/** Parse and normalize the JSON editor payload before persisting it. */
function parsePlayerBoard(input: string | PlayerInfo['playerboard']): PlayerInfo['playerboard'] | null {
let value: any = input;
if (typeof input === 'string') {
try {
value = input.trim() ? JSON.parse(input) : {};
} catch (error) {
console.warn('[gitadora] Ignoring invalid playerboard JSON:', error);
return null;
}
}
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const numberOr = (candidate: unknown, fallback: number) => {
const parsed = typeof candidate === 'number' ? candidate : Number(candidate);
return Number.isFinite(parsed) ? parsed : fallback;
};
const boolOr = (candidate: unknown, fallback: boolean) => {
if (candidate === undefined || candidate === null || candidate === '') return fallback;
if (typeof candidate === 'boolean') return candidate;
if (candidate === 1 || candidate === '1' || candidate === 'true') return true;
if (candidate === 0 || candidate === '0' || candidate === 'false') return false;
return fallback;
};
const stickers = Array.isArray(value.sticker) ? value.sticker : [];
const normalizedStickers = stickers.slice(0, 10).reduce((result: any[], sticker: any) => {
if (!sticker || typeof sticker !== 'object') return result;
const position = Array.isArray(sticker.position) ? sticker.position : [];
const scale = Array.isArray(sticker.scale) ? sticker.scale : [];
const id = numberOr(sticker.id, NaN);
const posX = numberOr(sticker.pos_x ?? position[0], NaN);
const posY = numberOr(sticker.pos_y ?? position[1], NaN);
const scaleX = numberOr(sticker.scale_x ?? scale[0], NaN);
const scaleY = numberOr(sticker.scale_y ?? scale[1], NaN);
const rotate = numberOr(sticker.rotate ?? sticker.rotation, NaN);
if (![id, posX, posY, scaleX, scaleY, rotate].every(Number.isFinite)) return result;
result.push({
id: Math.trunc(id),
pos_x: posX,
pos_y: posY,
scale_x: scaleX,
scale_y: scaleY,
rotate,
});
return result;
}, []);
return {
index: Math.trunc(numberOr(value.index, 1)),
is_active: boolOr(value.is_active, false),
sticker: normalizedStickers,
};
}
/**
* Replace the rival list for one game/version pair.
*
@@ -48,12 +109,13 @@ export const updateRival = async (data: {
const requestedRefids = [data.rival1, data.rival2, data.rival3, data.rival4, data.rival5];
const seenRefids = new Set<string>();
const rivalSlots = requestedRefids.flatMap((input, index) => {
const rivalSlots = requestedRefids.reduce((result: Array<{ rivalRefid: string; slot: number }>, input, index) => {
const rivalRefid = input?.trim();
if (!rivalRefid || rivalRefid === data.refid || seenRefids.has(rivalRefid)) return [];
if (!rivalRefid || rivalRefid === data.refid || seenRefids.has(rivalRefid)) return result;
seenRefids.add(rivalRefid);
return [{ rivalRefid, slot: index + 1 }];
});
result.push({ rivalRefid, slot: index + 1 });
return result;
}, [] as Array<{ rivalRefid: string; slot: number }>);
// Store only players that have a profile in the same game version. This keeps
// the later game response from referencing a player whose DID/profile is absent.
@@ -82,3 +144,15 @@ export const updateRival = async (data: {
});
}
};

View File

@@ -7,19 +7,33 @@ export interface PlayerStickerResponse {
rotate: KITEM<'float'>
}
export interface PlayerBoardConfig {
index: KITEM<'s32'>;
is_active: KITEM<'bool'>;
sticker: PlayerStickerResponse[];
}
function finiteNumber(value: unknown, fallback = 0): number {
const number = typeof value === 'number' ? value : Number(value);
return Number.isFinite(number) ? number : fallback;
}
/** Convert legacy card documents and WebUI playerboard JSON to wire values. */
export function getPlayerStickerResponse(playerCard : any[]) : PlayerStickerResponse[] {
let stickers : PlayerStickerResponse[] = []
if (!_.isArray(playerCard)) {
return stickers
}
for (const item of playerCard) {
for (const item of playerCard.slice(0, 10)) {
const id = _.get(item, 'id');
const posX = _.get(item, 'position.0');
const posY = _.get(item, 'position.1');
const scaleX = _.get(item, 'scale.0');
const scaleY = _.get(item, 'scale.1');
const rotation = _.get(item, 'rotation');
// Accept both the historical card shape and the wire-shaped playerboard
// shape used by the editor.
const posX = _.get(item, 'pos_x', _.get(item, 'position.0'));
const posY = _.get(item, 'pos_y', _.get(item, 'position.1'));
const scaleX = _.get(item, 'scale_x', _.get(item, 'scale.0'));
const scaleY = _.get(item, 'scale_y', _.get(item, 'scale.1'));
const rotation = _.get(item, 'rotate', _.get(item, 'rotation'));
if (
!isFinite(id) ||
@@ -33,14 +47,35 @@ export function getPlayerStickerResponse(playerCard : any[]) : PlayerStickerResp
}
stickers.push({
id: K.ITEM('s32', id),
pos_x: K.ITEM('float', posX),
pos_y: K.ITEM('float', posY),
scale_x: K.ITEM('float', scaleX),
scale_y: K.ITEM('float', scaleY),
rotate: K.ITEM('float', rotation),
id: K.ITEM('s32', finiteNumber(id)),
pos_x: K.ITEM('float', finiteNumber(posX)),
pos_y: K.ITEM('float', finiteNumber(posY)),
scale_x: K.ITEM('float', finiteNumber(scaleX)),
scale_y: K.ITEM('float', finiteNumber(scaleY)),
rotate: K.ITEM('float', finiteNumber(rotation)),
});
}
return stickers
}
return stickers
}
/** Build the playerboard response, with a safe fallback for old playerinfo documents. */
export function getPlayerBoardResponse(playerInfo: any): PlayerBoardConfig {
const configured = playerInfo?.playerboard;
const legacyCard = playerInfo?.card;
const stickers = getPlayerStickerResponse(
_.isArray(configured?.sticker) ? configured.sticker : legacyCard
);
const index = finiteNumber(configured?.index, 0);
const activeValue = configured?.is_active;
const isActive = activeValue === undefined
? _.isArray(legacyCard)
: Boolean(activeValue === true || activeValue === 1 || activeValue === '1' || activeValue === 'true');
return {
index: K.ITEM('s32', Math.trunc(index)),
is_active: K.ITEM('bool', isActive ? 1 : 0),
sticker: stickers,
};
}

View File

@@ -17,7 +17,25 @@ export interface PlayerInfo {
rotation: number;
}[];
// TODO: Add Board things.
/**
* Playerboard data returned by gametop.get.
*
* `card` is kept for backwards compatibility with older documents. New
* edits should use this wire-shaped representation so every playerboard
* field can be changed from the WebUI.
*/
playerboard?: {
index: number;
is_active: boolean;
sticker: {
id: number;
pos_x: number;
pos_y: number;
scale_x: number;
scale_y: number;
rotate: number;
}[];
};
}
export function getDefaultPlayerInfo(version: string, id: number) : PlayerInfo {

View File

@@ -1,4 +1,5 @@
//DATA//
trbitems: JSON.parse(require("fs").readFileSync(process.cwd() + "/plugins/gitadora@asphyxia/data/trbitems.json", "utf8"))
info: DB.Find(refid, { collection: 'playerinfo' })
profile: DB.Find(refid, { collection: 'profile' })
rivals: DB.Find(refid, { collection: 'rival' })
@@ -16,7 +17,6 @@
return "Unknown"
}
}
function getFullGameVersion(shortVer) {
switch (shortVer) {
case "re" :
@@ -39,8 +39,46 @@
return "Unknown"
}
}
function getItemName(id) {
const item = (trbitems || []).find(entry => entry.id === Number(id));
return item ? item.name : `#${id}`;
}
function getPlayerboardValue(info) {
const configured = (info && info.playerboard) || {};
const legacy = Array.isArray(info && info.card) ? info.card : [];
const source = Array.isArray(configured.sticker) ? configured.sticker : legacy;
const board = source.length ? source[0] : {};
const stickerSource = source.length ? source.slice(1) : [];
const numberOr = (value, fallback) => {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
};
return {
index: 1,
board_id: Math.trunc(numberOr(board.id, 0)),
is_active: configured.is_active === undefined
? legacy.length > 0
: Boolean(configured.is_active === true || configured.is_active === 1 || configured.is_active === '1' || configured.is_active === 'true'),
sticker: stickerSource.map(item => {
item = item || {};
const position = Array.isArray(item.position) ? item.position : [];
const scale = Array.isArray(item.scale) ? item.scale : [];
return {
id: Math.trunc(numberOr(item.id, 0)),
pos_x: numberOr(item.pos_x === undefined ? position[0] : item.pos_x, 0),
pos_y: numberOr(item.pos_y === undefined ? position[1] : item.pos_y, 0),
scale_x: numberOr(item.scale_x === undefined ? scale[0] : item.scale_x, 1),
scale_y: numberOr(item.scale_y === undefined ? scale[1] : item.scale_y, 1),
rotate: numberOr(item.rotate === undefined ? item.rotation : item.rotate, 0)
};
})
};
}
-
div
each i in info
.card
@@ -49,13 +87,17 @@ div
span.icon
i.mdi.mdi-account-edit
| User Detail (#{getFullGameVersion(i.version)})
a.button.is-link.playerboard-link(href=('/plugin/gitadora@asphyxia/profile?refid=' + refid + '&view=playerboard') style="margin-left:auto")
span.icon
i.mdi.mdi-view-dashboard
span PlayerBoard
.card-content
form(method="post" action="/emit/updatePlayerInfo")
.field
.field.playerboard-meta-field
label.label ID
.control
input.input(type="text" name="refid", value=refid readonly)
.field
.field.playerboard-meta-field
label.label Version
.control
input.input(type="text" name="version", value=i.version readonly)
@@ -67,6 +109,33 @@ div
label.label Title
.control
input.input(type="text" name="title", value=i.title)
- const boardValue = getPlayerboardValue(i)
.field
.field.is-hidden(data-playerboard-section)#playerboard
label.label Playerboard
.box.playerboard-editor(data-playerboard-editor)
.columns.is-variable.is-4
.column.is-half
p.help Preview (drag a sticker to change its position)
.playerboard-preview(data-board-preview)
.playerboard-preview-grid
.column.is-half
.field
label.label Board
.control
select.input(data-board-id)
each item in trbitems.filter(entry => entry.stk_category === 0)
option(value=item.id selected=item.id === boardValue.board_id)= `[${item.id}]: ${item.name}`
input.input(type="hidden" data-board-index value="1")
.field
label.label Stickers
.playerboard-sticker-list(data-sticker-list)
.buttons
button.button.is-link.is-light(type="button" data-add-sticker)
span.icon
i.mdi.mdi-plus
span Add sticker
textarea.is-hidden(data-board-json name="playerboard")= JSON.stringify(boardValue)
.field
button.button.is-primary(type="submit")
span.icon
@@ -153,3 +222,312 @@ div
span.icon
i.mdi.mdi-check
span Save rivals
style.
.playerboard-fullscreen .playerboard-meta-field { display: none; }
.playerboard-fullscreen .card-content > form > .field:not([data-playerboard-section]):not(:has(button[type="submit"])) { display: none; }
.playerboard-fullscreen .card:not(:has([data-playerboard-section])) { display: none; }
.playerboard-fullscreen .card-content > * { display: none; }
.playerboard-fullscreen .card-content > form { display: block !important; }
.playerboard-fullscreen .card-content > form > [data-playerboard-section] { display: block !important; }
.playerboard-fullscreen [data-playerboard-section] { display: block !important; }
.playerboard-editor { background: #000; color: #fff; }
.playerboard-preview {
position: relative;
width: 320px;
height: 554px;
margin: 0 auto;
flex: 0 0 320px;
overflow: hidden;
border: 1px solid #dbdbdb;
border-radius: 6px;
background: linear-gradient(135deg, #20243a, #384d69);
}
.playerboard-preview-grid {
position: absolute;
inset: 0;
opacity: .25;
background-image: linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px);
background-size: 10% 10%;
}
.playerboard-sticker {
position: absolute;
z-index: 2;
min-width: 38px;
min-height: 30px;
padding: 4px 7px;
border: 1px solid #666;
border-radius: 5px;
color: #fff;
background: #000;
box-shadow: 0 2px 5px rgba(0,0,0,.35);
cursor: grab;
transform-origin: center;
}
.playerboard-sticker:active { cursor: grabbing; }
.playerboard-sticker-row { margin-bottom: .35rem; padding: .35rem; background: #000; color: #fff; border: 1px solid #555; border-radius: 5px; }
.playerboard-sticker-row .field { margin-bottom: .15rem; }
.playerboard-sticker-row .label { font-size: .75rem; color: #fff; }
.playerboard-editor .label, .playerboard-editor .help { color: #fff; }
.playerboard-empty { color: #777; font-size: .9rem; padding: .5rem 0; }
script.
(() => {
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const numberOr = (value, fallback) => {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
};
const stickerOptions = !{JSON.stringify(trbitems.filter(item => item.stk_category !== 0).map(item => `<option value="${item.id}">[${item.id}]: ${item.name}</option>`).join(''))};
const stickerTemplate = () => `
<div class="playerboard-sticker-row" data-sticker-row>
<div class="columns is-mobile is-multiline">
<div class="column is-half"><label class="label">Sticker<select class="input" data-sticker-field="id">${stickerOptions}</select></label></div>
<div class="column is-half"><label class="label">Rotation<input class="input" type="number" step="0.01" data-sticker-field="rotate" value="1"></label></div>
<div class="column is-one-quarter"><label class="label">X<input class="input" type="number" step="0.01" data-sticker-field="pos_x" value="1"></label></div>
<div class="column is-one-quarter"><label class="label">Y<input class="input" type="number" step="0.01" data-sticker-field="pos_y" value="1"></label></div>
<div class="column is-one-quarter"><label class="label">Scale X<input class="input" type="number" step="0.01" data-sticker-field="scale_x" value="0.5"></label></div>
<div class="column is-one-quarter"><label class="label">Scale Y<input class="input" type="number" step="0.01" data-sticker-field="scale_y" value="0.5"></label></div>
</div>
<button class="button is-small is-danger is-light" type="button" data-remove-sticker>Remove sticker</button>
</div>`;
const getRows = editor => Array.from(editor.querySelectorAll('[data-sticker-row]'));
const readRows = editor => getRows(editor).map(row => {
const value = field => row.querySelector(`[data-sticker-field="${field}"]`).value;
return {
id: Math.trunc(numberOr(value('id'), 0)),
pos_x: numberOr(value('pos_x'), 0),
pos_y: numberOr(value('pos_y'), 0),
scale_x: numberOr(value('scale_x'), 1),
scale_y: numberOr(value('scale_y'), 1),
rotate: numberOr(value('rotate'), 0)
};
});
const BOARD_WIDTH = 320;
const BOARD_HEIGHT = 554;
const toXPercent = value => clamp(value / BOARD_WIDTH * 100, 0, 100);
const toYPercent = value => clamp(value / BOARD_HEIGHT * 100, 0, 100);
const fromXPercent = value => value / 100 * BOARD_WIDTH;
const fromYPercent = value => value / 100 * BOARD_HEIGHT;
const renderPreview = editor => {
const preview = editor.querySelector('[data-board-preview]');
const stickers = readRows(editor);
preview.querySelectorAll('.playerboard-sticker').forEach(item => item.remove());
stickers.forEach((sticker, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'playerboard-sticker';
button.dataset.stickerIndex = index;
button.textContent = `#${sticker.id}`;
button.title = 'Click to select; drag to reposition';
button.style.left = `${toXPercent(sticker.pos_x)}%`;
button.style.top = `${toYPercent(sticker.pos_y)}%`;
const sizeX = clamp(Math.abs(sticker.scale_x) * 28, 24, 100);
const sizeY = clamp(Math.abs(sticker.scale_y) * 22, 20, 80);
button.style.minWidth = `${sizeX}px`;
button.style.minHeight = `${sizeY}px`;
button.style.transform = `translate(-50%, 50%) rotate(${sticker.rotate}deg)`;
button.addEventListener('click', () => {
const row = getRows(editor)[index];
row && row.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
let drag;
button.addEventListener('pointerdown', event => {
event.preventDefault();
button.setPointerCapture(event.pointerId);
const bounds = preview.getBoundingClientRect();
drag = { bounds };
});
button.addEventListener('pointermove', event => {
if (!drag) return;
const x = clamp((event.clientX - drag.bounds.left) / drag.bounds.width * 100, 0, 100);
const y = clamp((event.clientY - drag.bounds.top) / drag.bounds.height * 100, 0, 100);
const row = getRows(editor)[index];
if (!row) return;
row.querySelector('[data-sticker-field="pos_x"]').value = fromXPercent(x).toFixed(2);
row.querySelector('[data-sticker-field="pos_y"]').value = fromYPercent(y).toFixed(2);
sync(editor);
});
button.style.left = `${x}%`;
button.style.top = `${y}%`;
button.addEventListener('pointerup', event => { if (drag) { try { button.releasePointerCapture(event.pointerId); } catch (_) {} } drag = null; });
button.addEventListener('pointercancel', () => { drag = null; });
preview.appendChild(button);
});
};
const sync = editor => {
const json = {
index: 1,
is_active: true,
sticker: [{ id: Math.trunc(numberOr(editor.querySelector('[data-board-id]').value, 0)), pos_x: 160, pos_y: 235, scale_x: 1, scale_y: 1, rotate: 0 }].concat(readRows(editor))
};
editor.querySelector('[data-board-json]').value = JSON.stringify(json);
};
const addSticker = (editor, sticker = {}) => {
const list = editor.querySelector('[data-sticker-list]');
if (list.querySelectorAll('[data-sticker-row]').length >= 10) return;
list.insertAdjacentHTML('beforeend', stickerTemplate());
const row = list.lastElementChild;
Object.entries({ id: 0, pos_x: 160, pos_y: 235, scale_x: 0.5, scale_y: 0.5, rotate: 0, ...sticker }).forEach(([field, value]) => {
row.querySelector(`[data-sticker-field="${field}"]`).value = value;
});
};
const initialize = editor => {
let initial = {};
try { initial = JSON.parse(editor.querySelector('[data-board-json]').value || '{}'); } catch (_) { initial = {}; }
const stickers = Array.isArray(initial.sticker) ? initial.sticker : [];
stickers.forEach(sticker => addSticker(editor, sticker));
editor.querySelector('[data-board-index]').value = 1;
editor.addEventListener('input', event => {
if (event.target.matches('[data-board-index], [data-sticker-field]')) {
sync(editor);
renderPreview(editor);
}
});
editor.addEventListener('change', event => {
if (event.target.matches('[data-board-index], [data-sticker-field]')) { sync(editor); renderPreview(editor); }
});
editor.querySelector('[data-add-sticker]').addEventListener('click', () => { addSticker(editor); sync(editor); renderPreview(editor); });
editor.closest('form').addEventListener('submit', () => sync(editor));
editor.addEventListener('click', event => {
if (false) {
addSticker(editor);
sync(editor);
renderPreview(editor);
}
const remove = event.target.closest('[data-remove-sticker]');
if (remove) {
remove.closest('[data-sticker-row]').remove();
sync(editor);
renderPreview(editor);
}
});
sync(editor);
renderPreview(editor);
};
document.querySelectorAll('[data-playerboard-editor]').forEach(initialize);
document.querySelectorAll('[data-toggle-playerboard]').forEach(button => {
button.addEventListener('click', () => {
const section = button.closest('.card').querySelector('[data-playerboard-section]');
if (section) section.classList.toggle('is-hidden');
});
});
})();
script.
document.addEventListener('click', function (event) {
const button = event.target.closest('[data-toggle-playerboard]');
if (!button) return;
const card = button.closest('.card');
const section = card && card.querySelector('[data-playerboard-section]');
if (section) section.classList.toggle('is-hidden');
});
script.
(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('view') !== 'playerboard') return;
const section = document.querySelector('[data-playerboard-section]');
if (!section) return;
section.classList.remove('is-hidden');
document.body.classList.add('playerboard-fullscreen');
})();
script.
(() => {
const isBoardView = new URLSearchParams(location.search).get('view') === 'playerboard';
const toggle = document.querySelector('.playerboard-link');
if (toggle && isBoardView) {
toggle.textContent = 'Back to Profile';
toggle.href = location.pathname + '?refid=' + encodeURIComponent(new URLSearchParams(location.search).get('refid') || '');
}
const editor = document.querySelector('[data-playerboard-editor]');
const preview = document.querySelector('[data-board-preview]');
if (!editor || !preview) return;
const rows = () => [...editor.querySelectorAll('[data-sticker-row]')];
const draw = () => {
preview.querySelectorAll('.playerboard-sticker').forEach(x => x.remove());
rows().forEach((row, index) => {
const x = Number(row.querySelector('[data-sticker-field="pos_x"]').value) || 0;
const y = Number(row.querySelector('[data-sticker-field="pos_y"]').value) || 0;
const b = document.createElement('button'); b.type='button'; b.className='playerboard-sticker'; b.textContent='#'+(row.querySelector('[data-sticker-field="id"]').value||0);
b.style.left=(x/320*100)+'%'; b.style.top=(y/554*100)+'%'; b.style.transform='translate(-50%,-50%)';
let moving=false; b.addEventListener('pointerdown',e=>{moving=true;b.setPointerCapture(e.pointerId)}); b.addEventListener('pointermove',e=>{if(!moving)return;const r=preview.getBoundingClientRect();const nx=Math.max(0,Math.min(320,(e.clientX-r.left)/r.width*320));const ny=Math.max(0,Math.min(554,(e.clientY-r.top)/r.height*554));row.querySelector('[data-sticker-field="pos_x"]').value=nx.toFixed(2);row.querySelector('[data-sticker-field="pos_y"]').value=ny.toFixed(2);b.style.left=(nx/320*100)+'%';b.style.top=(ny/554*100)+'%'}); b.addEventListener('pointerup',()=>moving=false); preview.appendChild(b);
});
};
window.playerboardRedraw = draw;
draw();
setInterval(() => { if (editor.querySelectorAll('[data-sticker-row]').length !== preview.querySelectorAll('.playerboard-sticker').length) draw(); }, 100);
})();
script.
(() => {
const editor = document.querySelector('[data-playerboard-editor]');
if (!editor) return;
const refresh = () => window.playerboardRedraw && window.playerboardRedraw();
editor.addEventListener('change', () => setTimeout(() => window.playerboardRedraw && window.playerboardRedraw(), 0));
editor.addEventListener('click', e => { if (e.target.closest('[data-add-sticker],[data-remove-sticker]')) setTimeout(() => window.playerboardRedraw && window.playerboardRedraw(), 20); });
new MutationObserver(() => refresh()).observe(editor.querySelector('[data-sticker-list]'), { childList: true });
})();