/* ---------- Workspace Data Model ---------- */
/* workspace schema:
workspace = {
objects: [
{ id, name, attributes:[{key,value}], lines:[ {id, name, attributes:[{key,value}], items:[{x,y,w,h,row,col,badge,thumbDataURL}]} ], activeLineId }
],
activeObjectId,
counters: { objects: 0, lines: 0 }
}
*/
const workspace = {
objects: [],
activeObjectId: null,
counters: { objects: 0, lines: 0 }
};
/* ---------- DOM Elements ---------- */
let objectsBar, objectPills, addObjectBtn, renameObjectBtn, removeObjectBtn;
let linesBar, linePills, addLineBtn, renameLineBtn, removeLineBtn, clearLineBtn;
let tankStrip, tankCount, workspaceGridEl;
/* ---------- ID Management ---------- */
function renumberObjectIds(obj) {
let counter = 1;
// Set object ID
let objIdAttr = obj.attributes.find(attr => attr.key === 'id');
if (objIdAttr) {
objIdAttr.value = counter.toString();
}
counter++;
// Renumber lines and their tiles sequentially
obj.lines.forEach(line => {
// Set line ID
let lineIdAttr = line.attributes.find(attr => attr.key === 'id');
if (lineIdAttr) {
lineIdAttr.value = counter.toString();
}
counter++;
// Set tile IDs for this line
line.items.forEach(tile => {
tile.id = counter;
counter++;
});
});
// Redraw grid to update ID previews
if (window.CoreAPI && typeof window.CoreAPI.drawGrid === 'function') {
window.CoreAPI.drawGrid();
}
}
/* ---------- Active Getters ---------- */
function activeObject() {
return workspace.objects.find(o => o.id === workspace.activeObjectId) || null;
}
function activeLine() {
const o = activeObject();
if(!o) return null;
return o.lines.find(l => l.id === o.activeLineId) || null;
}
/* ---------- Object Management ---------- */
function renderObjects() {
try {
objectPills.innerHTML = '';
for (const o of workspace.objects) {
const b = document.createElement('button');
b.className = 'obj-pill' + (o.id === workspace.activeObjectId ? ' active' : '');
b.textContent = o.name;
b.onclick = () => {
workspace.activeObjectId = o.id;
if(!o.activeLineId && o.lines[0]) o.activeLineId = o.lines[0].id;
renderObjects();
renderLines();
renderActiveTank();
};
// Add double-click to open properties
b.ondblclick = () => {
if (window.AttributesAPI && typeof window.AttributesAPI.openObjectView === 'function') {
window.AttributesAPI.openObjectView();
}
};
objectPills.appendChild(b);
}
} catch (error) {
alert(`Error rendering objects: ${error.message}`);
}
}
function addObject() {
try {
const idx = workspace.objects.length + 1;
const obj = {
id: window.CoreAPI.rid(), // Keep complex ID for internal tracking
name: `Object ${idx}`,
attributes: [
{ key: 'id', value: '1' } // Will be renumbered
],
lines: [{
id: window.CoreAPI.rid(), // Keep complex ID for internal tracking
name: 'Line 1',
attributes: [
{ key: 'id', value: '2' }, // Will be renumbered
{ key: 'url', value: '' }
],
items: []
}],
activeLineId: null
};
obj.activeLineId = obj.lines[0].id;
workspace.objects.push(obj);
workspace.activeObjectId = obj.id;
// Renumber everything in this object
renumberObjectIds(obj);
renderObjects();
renderLines();
renderActiveTank();
} catch (error) {
alert(`Error adding object: ${error.message}`);
}
}
function renameObject() {
try {
const o = activeObject();
if(!o) return;
const name = prompt('Rename object:', o.name);
if (name && name.trim()) {
o.name = name.trim();
renderObjects();
}
} catch (error) {
alert(`Error renaming object: ${error.message}`);
}
}
function removeObject() {
try {
if (workspace.objects.length === 1) {
alert('At least one object is required.');
return;
}
const idx = workspace.objects.findIndex(o => o.id === workspace.activeObjectId);
if (idx >= 0) {
workspace.objects.splice(idx, 1);
const next = workspace.objects[Math.max(0, idx - 1)];
workspace.activeObjectId = next.id;
renderObjects();
renderLines();
renderActiveTank();
}
} catch (error) {
alert(`Error removing object: ${error.message}`);
}
}
/* ---------- Line Management ---------- */
function renderLines() {
try {
const o = activeObject();
linePills.innerHTML = '';
if (!o) return;
for (const l of o.lines) {
const b = document.createElement('button');
b.className = 'line-pill' + (l.id === o.activeLineId ? ' active' : '');
b.textContent = l.name;
b.onclick = () => {
o.activeLineId = l.id;
renderLines();
renderActiveTank();
};
linePills.appendChild(b);
}
} catch (error) {
alert(`Error rendering lines: ${error.message}`);
}
}
function addLine() {
try {
const o = activeObject();
if(!o) return;
const idx = o.lines.length + 1;
const line = {
id: window.CoreAPI.rid(), // Keep complex ID for internal tracking
name: `Line ${idx}`,
attributes: [
{ key: 'id', value: '0' }, // Will be renumbered
{ key: 'url', value: '' }
],
items: []
};
o.lines.push(line);
o.activeLineId = line.id;
// Renumber everything in this object
renumberObjectIds(o);
renderLines();
renderActiveTank();
} catch (error) {
alert(`Error adding line: ${error.message}`);
}
}
function renameLine() {
try {
const o = activeObject();
if(!o) return;
const l = activeLine();
if(!l) return;
const name = prompt('Rename line:', l.name);
if (name && name.trim()) {
l.name = name.trim();
renderLines();
}
} catch (error) {
alert(`Error renaming line: ${error.message}`);
}
}
function removeLine() {
try {
const o = activeObject();
if(!o) return;
if (o.lines.length === 1) {
alert('At least one line is required.');
return;
}
const idx = o.lines.findIndex(l => l.id === o.activeLineId);
if (idx >= 0) {
o.lines.splice(idx, 1);
const next = o.lines[Math.max(0, idx - 1)];
o.activeLineId = next.id;
// Renumber everything in this object
renumberObjectIds(o);
renderLines();
renderActiveTank();
}
} catch (error) {
alert(`Error removing line: ${error.message}`);
}
}
function clearLine() {
try {
const l = activeLine();
if(!l) return;
l.items = [];
// Renumber everything in the object after clearing tiles
const o = activeObject();
if (o) renumberObjectIds(o);
renderActiveTank();
} catch (error) {
alert(`Error clearing line: ${error.message}`);
}
}
/* ---------- Tank Rendering ---------- */
function renderActiveTank() {
try {
const l = activeLine();
tankStrip.innerHTML = '';
if (!l) {
tankCount.textContent = '0 items';
return;
}
l.items.forEach((t, i) => {
const item = document.createElement('div');
item.className = 'tank-item';
item.title = `Index: ${t.index || 'N/A'}, Frame: ${t.frameKey || 'N/A'}\nSize: ${t.w}×${t.h} | Center: (${t.centerX || t.x},${t.centerY || t.y})\nType: ${t.type || 'static'}`;
const img = document.createElement('img');
img.src = t.thumbDataURL;
const badge = document.createElement('div');
badge.className = 'badge';
badge.textContent = t.id !== undefined ? `#${t.id}` : t.badge;
const remove = document.createElement('button');
remove.className = 'remove';
remove.textContent = '×';
remove.title = 'Remove';
remove.onclick = () => {
l.items.splice(i, 1);
// Renumber everything in the object after removing tile
const o = activeObject();
if (o) renumberObjectIds(o);
renderActiveTank();
};
item.appendChild(img);
item.appendChild(badge);
item.appendChild(remove);
tankStrip.appendChild(item);
});
const totalFrames = l.items.reduce((sum, item) => sum + (item.frameCount || 1), 0);
tankCount.textContent = `${l.items.length} tile${l.items.length === 1 ? '' : 's'} (${totalFrames} frame${totalFrames === 1 ? '' : 's'})`;
} catch (error) {
alert(`Error rendering tank: ${error.message}`);
}
}
/* ---------- Tile Capture ---------- */
function initTileCapture() {
try {
workspaceGridEl.addEventListener('click', (e) => {
try {
const tile = e.target.closest('.tile');
if (!tile) return;
if (!window.CoreAPI.imgEl) {
alert('Load an image first from the gallery');
return;
}
e.stopPropagation(); // avoid panning
const x = parseInt(tile.dataset.x, 10);
const y = parseInt(tile.dataset.y, 10);
const w = parseInt(tile.dataset.w, 10);
const h = parseInt(tile.dataset.h, 10);
let row = parseInt(tile.dataset.row, 10);
let col = parseInt(tile.dataset.col, 10);
if (window.CoreAPI.ONE_BASED) {
row += 1;
col += 1;
}
const badgeText = `r${row}c${col}`;
// make thumbnail
const cv = document.createElement('canvas');
cv.width = w;
cv.height = h;
const cctx = cv.getContext('2d');
cctx.imageSmoothingEnabled = false;
cctx.drawImage(window.CoreAPI.imgEl, x, y, w, h, 0, 0, w, h);
const dataURL = cv.toDataURL('image/png');
const l = activeLine();
if(!l) {
alert('No active line selected');
return;
}
// Get current object to access it for renumbering
const currentObject = activeObject();
if (!currentObject) {
alert('No active object');
return;
}
// Get current image info
const currentImageUrl = window.CoreAPI.imgEl?.src || '';
const imgWidth = window.CoreAPI.imgW;
const imgHeight = window.CoreAPI.imgH;
const tileWidth = w;
const tileHeight = h;
// Check if line already has tiles from a different image
if (l.items.length > 0) {
const lineUrlAttr = l.attributes.find(attr => attr.key === 'url');
const lineImageUrl = lineUrlAttr ? lineUrlAttr.value : '';
if (lineImageUrl && lineImageUrl !== currentImageUrl) {
alert(`This line already contains tiles from a different image.\nCreate a new line for tiles from this image.`);
return;
}
}
// Calculate automatic attributes
const cols = Math.floor(imgWidth / tileWidth);
const rows = Math.floor(imgHeight / tileHeight);
const index = row * cols + col;
const centerX = x + (tileWidth / 2);
const centerY = y + (tileHeight / 2);
// Generate deterministic keys for Phaser
const imageBaseName = currentImageUrl.split('/').pop().split('.')[0] || 'sprite';
const atlasKey = `${imageBaseName}_atlas`;
const frameKey = `${imageBaseName}_${index}`;
// Create comprehensive tile data (ID will be set by renumbering)
const tileData = {
// Original display data
x, y, w: tileWidth, h: tileHeight, row, col, badge: badgeText, thumbDataURL: dataURL,
// Automatic attributes (ID will be set during renumbering)
id: 0, // Placeholder, will be renumbered
url: currentImageUrl,
imgWidth,
imgHeight,
tileWidth,
tileHeight,
rows,
cols,
index,
frames: [index], // Single frame for now, can be extended for multi-tile
frameCount: 1,
centerX,
centerY,
atlasKey,
frameKey,
type: 'static'
};
// Add the tile to the line
l.items.push(tileData);
// If this is the first tile in the line, set up automatic line attributes
if (l.items.length === 1) {
// Update automatic attributes
const autoAttrs = {
url: currentImageUrl,
imgWidth: imgWidth.toString(),
imgHeight: imgHeight.toString(),
tileWidth: tileWidth.toString(),
tileHeight: tileHeight.toString(),
rows: rows.toString(),
cols: cols.toString()
};
// Set or update automatic attributes
Object.entries(autoAttrs).forEach(([key, value]) => {
let attr = l.attributes.find(a => a.key === key);
if (attr) {
attr.value = value;
} else {
l.attributes.push({ key, value });
}
});
// Add default changeable attributes if they don't exist
const defaultChangeableAttrs = {
frameRate: '10',
playMode: 'loop',
'origin.x': '0.5',
'origin.y': '0.5',
'physics.bodyType': 'static',
collides: 'false',
tags: '',
blockType: 'decor',
license: 'CC0'
};
Object.entries(defaultChangeableAttrs).forEach(([key, value]) => {
if (!l.attributes.find(a => a.key === key)) {
l.attributes.push({ key, value });
}
});
}
// Renumber everything in this object after adding tile
renumberObjectIds(currentObject);
renderActiveTank();
} catch (error) {
alert(`Error capturing tile: ${error.message}`);
}
});
} catch (error) {
alert(`Failed to initialize tile capture: ${error.message}`);
throw error;
}
}
/* ---------- Event Handlers ---------- */
function initWorkspaceEvents() {
try {
addObjectBtn.addEventListener('click', addObject);
renameObjectBtn.addEventListener('click', renameObject);
removeObjectBtn.addEventListener('click', removeObject);
addLineBtn.addEventListener('click', addLine);
renameLineBtn.addEventListener('click', renameLine);
removeLineBtn.addEventListener('click', removeLine);
clearLineBtn.addEventListener('click', clearLine);
} catch (error) {
alert(`Failed to initialize workspace events: ${error.message}`);
throw error;
}
}
/* ---------- Initialization ---------- */
function initializeWorkspace() {
try {
// Get DOM elements
objectsBar = document.getElementById('objectsBar');
objectPills = document.getElementById('objectPills');
addObjectBtn = document.getElementById('addObjectBtn');
renameObjectBtn = document.getElementById('renameObjectBtn');
removeObjectBtn = document.getElementById('removeObjectBtn');
linesBar = document.getElementById('linesBar');
linePills = document.getElementById('linePills');
addLineBtn = document.getElementById('addLineBtn');
renameLineBtn = document.getElementById('renameLineBtn');
removeLineBtn = document.getElementById('removeLineBtn');
clearLineBtn = document.getElementById('clearLineBtn');
tankStrip = document.getElementById('tankStrip');
tankCount = document.getElementById('tankCount');
workspaceGridEl = document.getElementById('grid');
// Check required DOM elements and dependencies
if (!objectsBar || !tankStrip || !workspaceGridEl) {
throw new Error('Required DOM elements not found');
}
if (!window.CoreAPI || typeof window.CoreAPI.rid !== 'function') {
throw new Error('CoreAPI not available');
}
// Create initial object
const initialObject = {
id: window.CoreAPI.rid(), // Keep complex ID for internal tracking
name: 'Object 1',
attributes: [
{ key: 'id', value: '1' } // Will be renumbered
],
lines: [{
id: window.CoreAPI.rid(), // Keep complex ID for internal tracking
name: 'Line 1',
attributes: [
{ key: 'id', value: '2' }, // Will be renumbered
{ key: 'url', value: '' }
],
items: []
}],
activeLineId: null
};
initialObject.activeLineId = initialObject.lines[0].id;
workspace.objects.push(initialObject);
workspace.activeObjectId = initialObject.id;
// Renumber the initial object
renumberObjectIds(initialObject);
initWorkspaceEvents();
initTileCapture();
renderObjects();
renderLines();
renderActiveTank();
} catch (error) {
alert(`Workspace module failed to initialize: ${error.message}`);
throw error;
}
}
// Export API for other modules
window.WorkspaceAPI = {
workspace,
activeObject,
activeLine,
renderObjects,
renderLines,
renderActiveTank
};