FQCrop Batch: Difference between revisions
Jump to navigation
Jump to search
Created page with "The following is a script you can use along with the Creating Advanced Art Layers Guide to speed up connecting your advanced art layers to your FQ mod. To use it, simply copy the content into a new text file and name it fqcrop_batch.jsx You can either put this in your Phothoshop/Presets/Scripts folder and restart photoshop to use it from file > scripts. Or you can go to file > scripts > browse. var startRulesUnits = preferences.rulerU..." |
No edit summary |
||
| Line 5: | Line 5: | ||
You can either put this in your Phothoshop/Presets/Scripts folder and restart photoshop to use it from file > scripts. Or you can go to file > scripts > browse. | You can either put this in your Phothoshop/Presets/Scripts folder and restart photoshop to use it from file > scripts. Or you can go to file > scripts > browse. | ||
<pre> | |||
// This is a helper script for all ASSET editors | |||
import Window from '../WindowManager.js'; | |||
import Condition from '../../classes/Condition.js'; | |||
import Generic from '../../classes/helpers/Generic.js'; | |||
import Audio from '../../classes/Audio.js'; | |||
import * as EditorPlayerIconState from './EditorPlayerIconState.js'; | |||
import PlayerIconState from '../../classes/PlayerIconState.js'; | |||
export default{ | |||
PAGINATION_LENGTH : 250, | |||
TEST_AUDIO : null, | |||
// Automatically binds all inputs, textareas, and selects with the class saveable and a name attribute indicating the field to to save | |||
// win is a windowmanager object | |||
// Autobind ADDS an event, so you can use el.eventType = fn before calling autobind if you want | |||
// List is the name of the array in mod.<array> that the asset is stored in | |||
// Also binds JSON | |||
autoBind( win, asset, list, onChange ){ | |||
const dom = win.dom, | |||
dev = window.mod | |||
; | |||
const updateExact = (el, val) => { | |||
if( el ) | |||
el.innerText = '('+(val <= 0 && el.classList.contains("zeroParent") ? 'PASS' : val)+')'; | |||
}; | |||
const handleChange = event => { | |||
const targ = event.currentTarget, | |||
name = targ.name | |||
; | |||
let val = targ.value.trim(); | |||
if( targ.classList.contains('bitwise') ){ | |||
let v = 0; | |||
const all = [...dom.querySelectorAll('input[name="'+name+'"]')]; | |||
for( let sub of all ){ | |||
if( sub.checked && parseInt(sub.value) ) | |||
v = v|parseInt(sub.value); | |||
} | |||
val = v; | |||
} | |||
// Try to auto typecast | |||
else if( targ.dataset.type === 'smart' ){ | |||
if( val.toLowerCase() === 'true' ) | |||
val = true; | |||
else if( val.toLowerCase() === 'false' ) | |||
val = false; | |||
else if( !isNaN(val) ) | |||
val = +val; | |||
} | |||
else if( targ.dataset.type === 'int' ){ | |||
val = parseInt(val) || 0; | |||
} | |||
else if( targ.dataset.type === 'float' ){ | |||
val = +val || 0; | |||
} | |||
else if( targ.tagName === 'INPUT' ){ | |||
const type = targ.type; | |||
if( type === 'range' || type === 'number' ) | |||
val = +val; | |||
if( type === 'checkbox' ) | |||
val = Boolean(targ.checked); | |||
} | |||
let path = name.split('::'); | |||
let base = asset; | |||
while( path.length > 1 ){ | |||
base = base[path.shift()]; | |||
if( base === undefined ){ | |||
console.error("Path find failed for ", name, "in", asset); | |||
throw "Unable to find path, see above"; | |||
} | |||
} | |||
path = path.shift(); | |||
// Soft = here because type isn't important | |||
if( val != base[path] ){ | |||
// Label change must update the window | |||
if( name === "label" || name === "id" ){ | |||
val = val.trim(); | |||
if( !val ) | |||
throw 'Label/id cannot be empty'; | |||
win.id = val; | |||
win.updateTitle(); | |||
dev.mod.updateChildLabels(base, win.type, base[path], val); // Make sure any child objects notice the label change | |||
} | |||
else if( name === "name" ){ | |||
win.name = val; | |||
win.updateTitle(); | |||
} | |||
base[path] = val; | |||
dev.setDirty(true); | |||
if( list ) | |||
this.rebuildAssetLists(list); | |||
this.propagateChange(win); | |||
if( typeof onChange === "function" ) | |||
onChange(name, val); | |||
} | |||
updateExact(targ._valExact, val); | |||
}; | |||
// Binds simple inputs | |||
dom.querySelectorAll(".saveable[name]").forEach(el => { | |||
// Cache any exact val span | |||
el._valExact = el.parentElement.querySelector('span.valueExact'); | |||
if( el.parentElement.tagName !== 'LABEL' ) | |||
delete el._valExact; | |||
updateExact( el._valExact, el.value ); | |||
el.addEventListener('change', handleChange); | |||
}); | |||
this.bindJson( win, asset, list ); | |||
this.bindArrayPickers(win, asset, list); | |||
}, | |||
// Automatically binds JSON fields. Needs to be a textarea with class json and name being the field you want to put the data in | |||
bindJson( win, asset, list ){ | |||
const dev = window.mod; | |||
const updateData = (textarea, checkChange = true) => { | |||
try{ | |||
textarea.value = textarea.value.trim(); | |||
if( !textarea.value ) | |||
textarea.value = '{}'; | |||
let data = JSON.parse(textarea.value); | |||
textarea.value = JSON.stringify(data, undefined, 2); // Auto format | |||
// Check if it has changed | |||
if( JSON.stringify(data) !== JSON.stringify(asset.data) && checkChange ){ | |||
asset[textarea.name] = data; | |||
dev.setDirty(true); | |||
this.rebuildAssetLists(list); | |||
this.propagateChange(win); | |||
} | |||
}catch(err){ | |||
alert("JSON Syntax error: "+(err.message || err | |||
[[Category:Modding]] | [[Category:Modding]] | ||
Revision as of 17:49, 4 May 2024
The following is a script you can use along with the Creating Advanced Art Layers Guide to speed up connecting your advanced art layers to your FQ mod.
To use it, simply copy the content into a new text file and name it fqcrop_batch.jsx
You can either put this in your Phothoshop/Presets/Scripts folder and restart photoshop to use it from file > scripts. Or you can go to file > scripts > browse.
// This is a helper script for all ASSET editors
import Window from '../WindowManager.js';
import Condition from '../../classes/Condition.js';
import Generic from '../../classes/helpers/Generic.js';
import Audio from '../../classes/Audio.js';
import * as EditorPlayerIconState from './EditorPlayerIconState.js';
import PlayerIconState from '../../classes/PlayerIconState.js';
export default{
PAGINATION_LENGTH : 250,
TEST_AUDIO : null,
// Automatically binds all inputs, textareas, and selects with the class saveable and a name attribute indicating the field to to save
// win is a windowmanager object
// Autobind ADDS an event, so you can use el.eventType = fn before calling autobind if you want
// List is the name of the array in mod.<array> that the asset is stored in
// Also binds JSON
autoBind( win, asset, list, onChange ){
const dom = win.dom,
dev = window.mod
;
const updateExact = (el, val) => {
if( el )
el.innerText = '('+(val <= 0 && el.classList.contains("zeroParent") ? 'PASS' : val)+')';
};
const handleChange = event => {
const targ = event.currentTarget,
name = targ.name
;
let val = targ.value.trim();
if( targ.classList.contains('bitwise') ){
let v = 0;
const all = [...dom.querySelectorAll('input[name="'+name+'"]')];
for( let sub of all ){
if( sub.checked && parseInt(sub.value) )
v = v|parseInt(sub.value);
}
val = v;
}
// Try to auto typecast
else if( targ.dataset.type === 'smart' ){
if( val.toLowerCase() === 'true' )
val = true;
else if( val.toLowerCase() === 'false' )
val = false;
else if( !isNaN(val) )
val = +val;
}
else if( targ.dataset.type === 'int' ){
val = parseInt(val) || 0;
}
else if( targ.dataset.type === 'float' ){
val = +val || 0;
}
else if( targ.tagName === 'INPUT' ){
const type = targ.type;
if( type === 'range' || type === 'number' )
val = +val;
if( type === 'checkbox' )
val = Boolean(targ.checked);
}
let path = name.split('::');
let base = asset;
while( path.length > 1 ){
base = base[path.shift()];
if( base === undefined ){
console.error("Path find failed for ", name, "in", asset);
throw "Unable to find path, see above";
}
}
path = path.shift();
// Soft = here because type isn't important
if( val != base[path] ){
// Label change must update the window
if( name === "label" || name === "id" ){
val = val.trim();
if( !val )
throw 'Label/id cannot be empty';
win.id = val;
win.updateTitle();
dev.mod.updateChildLabels(base, win.type, base[path], val); // Make sure any child objects notice the label change
}
else if( name === "name" ){
win.name = val;
win.updateTitle();
}
base[path] = val;
dev.setDirty(true);
if( list )
this.rebuildAssetLists(list);
this.propagateChange(win);
if( typeof onChange === "function" )
onChange(name, val);
}
updateExact(targ._valExact, val);
};
// Binds simple inputs
dom.querySelectorAll(".saveable[name]").forEach(el => {
// Cache any exact val span
el._valExact = el.parentElement.querySelector('span.valueExact');
if( el.parentElement.tagName !== 'LABEL' )
delete el._valExact;
updateExact( el._valExact, el.value );
el.addEventListener('change', handleChange);
});
this.bindJson( win, asset, list );
this.bindArrayPickers(win, asset, list);
},
// Automatically binds JSON fields. Needs to be a textarea with class json and name being the field you want to put the data in
bindJson( win, asset, list ){
const dev = window.mod;
const updateData = (textarea, checkChange = true) => {
try{
textarea.value = textarea.value.trim();
if( !textarea.value )
textarea.value = '{}';
let data = JSON.parse(textarea.value);
textarea.value = JSON.stringify(data, undefined, 2); // Auto format
// Check if it has changed
if( JSON.stringify(data) !== JSON.stringify(asset.data) && checkChange ){
asset[textarea.name] = data;
dev.setDirty(true);
this.rebuildAssetLists(list);
this.propagateChange(win);
}
}catch(err){
alert("JSON Syntax error: "+(err.message || err));
let e = String(err).split(' ');
let pos = parseInt(e.pop());
textarea.focus();
textarea.setSelectionRange(pos, pos);
return false;
}
return true;
};
win.dom.querySelectorAll('textarea.json[name]').forEach(el => {
updateData(el, false);
el.addEventListener('change', event => {
if( !updateData(el) )
event.stopImmediatePropagation();
});
});
},
// Automatically binds arrayPickers (arrays of checkboxes). Needs to be a div with class arrayPicker and name being the field to put the resulting array in
bindArrayPickers( win, asset, list ){
const dev = window.mod;
const updateData = div => {
const out = [], pre = JSON.stringify(asset[div.getAttribute("name")] || []);
div.querySelectorAll('input[type=checkbox]:checked').forEach(el => {
out.push(el.value);
});
if( JSON.stringify(out) !== pre ){
asset[div.getAttribute("name")] = out;
dev.setDirty(true);
this.rebuildAssetLists(list);
this.propagateChange(win);
}
};
const picker = win.dom.querySelectorAll('div.arrayPicker[name]');
picker.forEach(pickerEl => {
pickerEl.querySelectorAll('input[type=checkbox]').forEach(el => el.addEventListener("change", () => {
updateData(pickerEl);
}));
updateData(pickerEl);
});
},
// Tries to convert an object, array etc to something that can be put into a table, and escaped
makeReadable( val ){
if( Array.isArray(val) ){
val = val.map(el => this.makeReadable(el)).join(', ');
}
else if( val === null ){
val = '{}';
}
else if( typeof val === "object" ){ // NULL is technically an object
if( val.label )
val = val.label;
else if( val.id )
val = val.id;
else
val = JSON.stringify(val);
}
return String(val);
},
// Takes a var and library name and turns it into an object (unless it's already one)
modEntryToObject( v, lib ){
if( typeof v === "string" )
v = this.getAssetById( lib, v );
return v;
},
// Creates a table inside an asset to show other assets. Such as a table of conditions tied to a text asset
// Returns a DOM table with events bound on it
// Asset is a mod asset
/*
win is the Window object that should act as the parent for the asset to link
asset is the parent asset
key is the key in the asset to modify
if parented, it sets the _mParent : {type:(str)type, label:(str)label} parameter on any new assets created, and only shows assets with the same _mParent set
if parented is === 2, it sets _h instead to hide it. Used only in sub assets of dungeon room assets to save memory
if single is === -1, then it hides all inputs
columns can also contain functions, they'll be run with the asset as an argument
ignoreAsset doesn't put the asset into the list. Used by EditorQuestReward where you have multiple fields mapping the same key to different types of objects
windowData is passed to the new window
*/
linkedTable( win, asset, key, constructor = Condition, targetLibrary = 'conditions', columns = ['id', 'label', 'desc'], single = false, parented = false, ignoreAsset = false, windowData = '', ignoreLibrary = false, allowUnique = true ){
const fullKey = key;
let k = key.split('::');
let entries = asset;
while( k.length > 1 ){
entries = entries[k.shift()];
}
key = k.shift();
const allEntries = toArray(entries[key]);
// Needed because :: may need to set '' as a value in order to work
if( single && single !== -1 && allEntries[0] === '' )
allEntries.splice(0);
const EDITOR = window.mod, MOD = EDITOR.mod;
let table = document.createElement("table");
table.classList.add("linkedTable", "selectable");
if( !ignoreAsset ){ // Used in EditorQuestReward where there are multiple inputs all corresponding to the same field
//console.log(key, targetLibrary, allEntries);
let n = 0;
for( let entry of allEntries ){
const base = this.modEntryToObject(entry, targetLibrary),
asset = new constructor(base)
;
if( !base ){
console.error("Base not found, trying to find", entry, "in", targetLibrary, "asset was", asset, "all assets", allEntries);
}
const tr = document.createElement('tr');
table.appendChild(tr);
tr.classList.add("asset");
tr.dataset.id = asset.label || asset.id;
tr.dataset.index = n;
if( base && typeof entry !== 'object' && (base.__MOD || base._e) ){
if( base.__MOD ){
tr.dataset.mod = base.__MOD;
}
if( base._e ){
tr.dataset.ext = base._e;
}
}
// prefer label before id
for( let column of columns ){
const td = document.createElement('td');
tr.appendChild(td);
td.innerText = this.makeReadable(typeof column === 'function' ? column(asset) : asset[column]);
}
let td = document.createElement("td");
tr.appendChild(td);
if( !base )
td.innerText = 'MISSING_ASSET';
else
td.innerText = (base.__MOD ? base.__MOD : 'THIS');
// order buttons
if( !single ){
td = document.createElement("td");
tr.appendChild(td);
td.classList.add("order");
if( n )
td.innerHTML += '<input type="button" class="small up" value="▲" />';
if( n !== allEntries.length-1 )
td.innerHTML += '<input type="button" class="small down" value="▼" />';
}
++n;
}
}
// Stores a created asset in this asset's key
// a is the new asset
const storeAsset = (a, pa) => {
console.log("Storing asset", a, pa);
let template = new constructor();
let text = (asset.label||asset.id)+'>>'+targetLibrary.substr(0, 3)+'_'+Generic.generateUUID().substr(0,4)
if( template.hasOwnProperty("label") )
a.label = text;
else
a.id = text;
// There's no target library, this should be stored as an object
if( !mod.mod[targetLibrary] ){
console.log("Note: Stored asset as object (may be unwanted)", a, "in", asset);
if( single )
entries[key] = a;
else{
if( !Array.isArray(entries[key]) )
entries[key] = [];
entries[key].push(a);
}
}
else if( single )
entries[key] = a.label || a.id; // Store only the ID
else{
if( !Array.isArray(entries[key]) )
entries[key] = [];
entries[key].push(a.label || a.id);
}
if( pa ){
if( pa === 2 )
a._h = 1;
else{
a._mParent = {
type : win.type,
label : win.id,
};
}
}
};
// Parented single asset can only add if one is missing. Otherwise they have to edit by clicking. This works because parented can only belong to the same mod.
const hasButton = (!single || !parented || !entries[key]) && single !== -1;
if( hasButton ){
const tr = document.createElement("tr");
table.appendChild(tr);
tr.classList.add("noselect");
//console.log("targetLibrary", targetLibrary, "ignore", ignoreLibrary);
tr.innerHTML = '<td class="center" colspan="'+(columns.length+1+(!single))+'">'+
(window.mod.hasDB(targetLibrary) && !ignoreLibrary ? '<input type="button" class="small addNew library" value="Library" />' : '')+
(allowUnique ? '<input type="button" class="small addNew" value="Unique" />' : '')+
'</td>';
table.querySelectorAll("input.addNew")?.forEach(el => el.onclick = event => {
const fromLibrary = event.target.classList.contains('library');
// If parented, insert a new asset immediately, as there's no point in listing assets that are only viable for this parent
// Holding shift key for a non-parent by default creates a parented one
if( !fromLibrary ){
let a = new constructor();
a = constructor.saveThis(a, "mod");
storeAsset(a, parented || true); // Must be either 2 or true when clicking the unique button, default to mParent
// Insert handles other window refreshers
this.insertAsset(targetLibrary, a, win, undefined, windowData);
// But we still need to refresh this
win.rebuild();
this.propagateChange(win);
}
else
window.mod.buildAssetLinker( win, asset, fullKey, targetLibrary, single );
});
}
const clickListener = event => {
const index = parseInt(event.currentTarget.dataset.index);
const entry = single ? entries[key] : entries[key][index];
const id = event.currentTarget.dataset.id;
let asset = this.getAssetById(targetLibrary, id);
// Ctrl deletes
if( event.ctrlKey || event.metaKey ){
// Remove an extension
if( event.currentTarget.dataset.ext ){
MOD.deleteAsset(targetLibrary, entry);
}
// Remove the actual thing
else{
// Don't need to store this param in the mod anymore
if( single )
delete entries[key];
// Remove from the array
else
entries[key].splice(index, 1); // Remove this
// Assets in lists are always strings, only the official mod can use objects because it's hardcoded
// If this table has a parenting relationship (see Mod.js), gotta remove it from the DB too
if( asset && (asset._h || asset._mParent) )
MOD.deleteAsset(targetLibrary, entry, true);
}
win.rebuild();
EDITOR.setDirty(true);
this.rebuildAssetLists(win.type);
this.propagateChange(win);
return;
}
else{
// Fetch from library
if( !asset ){
throw 'Linked asset not found, '+id+" in "+targetLibrary;
}
if( typeof asset !== "object" )
throw 'Linked asset is not an object';
if( event.altKey && !single ){
const a = new constructor(asset);
const inserted = this.insertCloneAsset(targetLibrary, a, constructor, win);
// Add it to the list
storeAsset(inserted, parented);
this.rebuildAssetLists(targetLibrary);
this.propagateChange(win);
win.rebuild();
return;
}
// This is just for legacy reasons, makes sure it has an ID, which the window manager wants
if( !asset.label && !asset.id && typeof entry === "object" )
entry.id = Generic.generateUUID();
// prefer editing by string since that can be put into save state, but custom assets can be edited via object for legacy reasons
EDITOR.buildAssetEditor( targetLibrary, entry, undefined, win, windowData );
}
};
const onArrowClick = event => {
const up = event.currentTarget.classList.contains("up"),
index = parseInt(event.currentTarget.parentNode.parentNode.dataset.index)
;
if( up ){
let pre = entries[key][index-1];
entries[key][index-1] = entries[key][index];
entries[key][index] = pre;
}
else{
let pre = entries[key][index+1];
entries[key][index+1] = entries[key][index];
entries[key][index] = pre;
}
win.rebuild();
EDITOR.setDirty(true);
this.propagateChange(win);
};
table.querySelectorAll("tr.asset").forEach(el => {
el.onclick = clickListener;
el.linkedTableListener = clickListener; // Stores the listener in the TR in case you want to override it
});
// Prevents default action when clicking one the td contining the arrows
table.querySelectorAll('td.order').forEach(el => {
el.onclick = event => event.stopImmediatePropagation();
});
table.querySelectorAll('td.order > input').forEach(el => el.onclick = onArrowClick);
// Todo: Need some way to refresh the window if one of the linked assets are changed
return table;
},
// Because list and linker use the same function, this can be used to check which it is
windowIsLinker( win ){
return win.type === 'linker';
},
// Tries to automatically build a selectable list of assets and return the HTML
// Fields is an array of {field:true/func} If you use a non function, it'll try to auto generate the value based on the type,
// otherwise the function will be executed using win as parent and supply the var as an argument
// If a field name starts with * it counts as essential
// Nonfunction is auto escaped, function needs manual escaping
// Fields should contain an id or label field (or both), it will be used for tracking the TR and prefer label if it exists
// Constructor is the asset constructor (used for default values)
buildList( win, library, constr, fields, start ){
let fulldb = window.mod.mod[library].slice().reverse(),
isLinker = this.windowIsLinker(win)
;
// Parent mod assets
fulldb.push(...window.mod.parentMod[library].slice().reverse());
// Filter out parented and extensions
fulldb = fulldb.filter(el =>
(!el._mParent && !el._e && !el._h) ||
window.mod.showParented
);
const fieldIsEssential = field => field.startsWith('*');
const getFieldName = field => {
if( fieldIsEssential(field) )
return field.slice(1);
return field;
};
// Used to stringify a key from fields that should exist in asset
const stringifyVal = (field, custom, asset) => {
if( typeof custom === "function" )
return custom.call(win, asset);
const val = asset[getFieldName(field)];
if( typeof val === "boolean" )
return val ? 'YES' : '';
return this.makeReadable(val);
};
if( !start )
start = parseInt(win.custom._page) || 0;
if( start === -1 )
start = fulldb.length;
win.custom._page = start;
if( win._search ){
let searchTerms = {}; // Object where key * searches all fields, otherwise 'key' : 'search'
try{
searchTerms = JSON.parse(win._search);
}catch(err){
searchTerms = {'*' : win._search};
}
for( let term in searchTerms )
searchTerms[term] = String(searchTerms[term]).toLowerCase();
// Use cached search results
if( win._searchResults )
fulldb = win._searchResults;
else{
// Returns true if the term validated
const findTermInEntry = (terms, entry) => {
terms = toArray(terms);
// Validates all terms
for( let searchTerm of terms ){
let inverse = false;
if( searchTerm.startsWith("!") ){
inverse = true;
searchTerm = searchTerm.substr(1);
}
let found = entry.includes(searchTerm);
if( found === inverse )
return false;
}
return true;
};
// Find out how many fields we need, * is only counted if it's the only field
let minTerms = Object.keys(searchTerms).length;
if( minTerms > 1 && searchTerms['*'] )
--minTerms;
fulldb = win._searchResults = fulldb.filter(el => {
let fieldsFound = 0;
for( let i in fields ){
let fieldName = i;
if( fieldName.charAt(0) === '*' )
fieldName = fieldName.substring(1);
let texts = [];
if( searchTerms['*'] )
texts.push(searchTerms['*']);
if( searchTerms[fieldName] )
texts.push(searchTerms[fieldName]);
// no search terms for this field
if( !texts.length )
continue;
// Ignore this field
if( window.mod.essentialOnly && !fieldIsEssential(i) )
continue;
let data = stringifyVal(fieldName, fields[i], el);
const text = typeof data === 'string' ? data.toLowerCase() : String(data);
// Wildcard found
if( findTermInEntry( texts, text ) ){
++fieldsFound;
// Found enough fields
if( fieldsFound >= minTerms )
return true;
continue;
}
/// {"chat":1,"conditions":"eventisbattlestarted"}
}
return false;
});
}
}
let lastPageStarts = Math.floor(fulldb.length/this.PAGINATION_LENGTH);
if( fulldb.length%this.PAGINATION_LENGTH === 0 && fulldb.length )
lastPageStarts -= 1;
lastPageStarts *= this.PAGINATION_LENGTH;
if( win.custom._page > lastPageStarts )
win.custom._page = lastPageStarts;
let db = fulldb.slice(win.custom._page, win.custom._page+this.PAGINATION_LENGTH);
// Create the element to return
const container = document.createElement('template');
let el = document.createElement('div');
el.innerText = 'To search specific fields use a JSON object {"fieldName":"searchTerm", "*":"globalSearchTerm"}. You can use ! at the start of a search term to inverse.';
container.appendChild(el);
// "new" button
el = document.createElement('input');
container.appendChild(el);
el.classList.add('new');
el.type = 'button';
el.value = 'New';
// Search bar
el = document.createElement('input');
container.appendChild(el);
el.classList.add('search');
el.type = 'text';
el.placeholder = 'Search';
// Batch operation span
el = document.createElement('span');
container.appendChild(el);
el.classList.add('hidden', 'batch');
// Batch delete
let elSub = document.createElement('input');
el.appendChild(elSub);
elSub.classList.add('deleteSelected');
elSub.type = 'button';
elSub.value = 'Delete Selected';
elSub = document.createElement('input');
el.appendChild(elSub);
elSub.classList.add('exportSelected');
elSub.type = 'button';
elSub.value = 'Export Selected';
const table = document.createElement('table');
container.appendChild(table);
table.classList.add('dblist', 'selectable', 'autosize');
let tr = document.createElement('tr');
table.appendChild(tr);
// Add checkbox placeholder
if( !isLinker ){
let th = document.createElement('th');
tr.appendChild(th);
th.classList.add("essential");
let checkbox = document.createElement('input');
th.appendChild(checkbox);
checkbox.classList.add('checkAll');
checkbox.type = 'checkbox';
}
for( let i in fields ){
let th = document.createElement('th');
tr.appendChild(th);
if( fieldIsEssential(i) )
th.classList.add('essential');
th.innerText = getFieldName(i);
}
// mod shows up in linker
if( isLinker ){
let th = document.createElement('th');
tr.appendChild(th);
th.innerText = "MOD";
}
for( let asset of db ){
const a = constr.loadThis(asset);
let tr = document.createElement('tr');
table.appendChild(tr);
tr.dataset.id = asset.label || asset.id || a.label || a.id;
let ext = a;
// This asset is not from this mod
if( asset.__MOD ){
tr.dataset.mod = asset.__MOD;
}
ext = window.mod.parentMod.getAssetById(library, asset.label || asset.id, true) || a;
if( (ext.id && ext.id !== a.id) || (ext.label && ext.label !== a.label) ) // This is an extension on the base mod
tr.dataset.ext = ext.id || ext.label;
if( !isLinker ){
let td = document.createElement('td');
tr.appendChild(td);
td.classList.add('essential');
td.innerHTML = '<input type="checkbox" class="marker" />';
}
for( let field in fields ){
const essential = fieldIsEssential(field);
let val = stringifyVal(field, fields[field], ext);
const td = document.createElement('td');
tr.appendChild(td);
if( essential )
td.classList.add("essential");
td.innerText = val;
}
// Linker should also show the mod
if( isLinker ){
const td = document.createElement('td');
tr.appendChild(td);
td.innerText = asset.__MOD ? asset.__MOD : 'THIS';
}
}
container.appendChild(table);
if( start > 0 ){
let back = document.createElement('input');
back.type = 'button';
back.className = 'backFull';
back.value = '<<<<';
container.appendChild(back);
back = document.createElement('input');
back.type = 'button';
back.className = 'back';
back.value = '<<';
container.appendChild(back);
}
if( fulldb.length > this.PAGINATION_LENGTH ){
const paginate = document.createElement('span');
paginate.innerText = ' '+(win.custom._page/this.PAGINATION_LENGTH)+'/'+Math.floor(Math.max(0,fulldb.length-1)/this.PAGINATION_LENGTH)+' ';
container.append(paginate);
}
if( fulldb.length > win.custom._page+this.PAGINATION_LENGTH ){
let next = document.createElement('input');
next.type = 'button';
next.className = 'next';
next.value = '>>';
container.append(next);
next = document.createElement('input');
next.type = 'button';
next.className = 'last';
next.value = '>>>>';
container.append(next);
}
return container;
},
// Binds a window listing. This is used for the window types: database, linker
// Type is the name of the array of the asset in mod the list fetches elements from
// baseObject is the object to insert when pressing "new"
bindList( win, type, baseObject ){
const DEV = window.mod, MOD = DEV.mod;
const isLinker = this.windowIsLinker(win),
single = isLinker && win.asset && win.asset.single, // This is a linker for only ONE object, otherwise it's an array
batchDiv = win.dom.querySelector('span.batch'),
rows = win.dom.querySelectorAll('tr[data-id]'), // TRs in the table
nextButton = win.dom.querySelector('input.next'),
lastButton = win.dom.querySelector('input.last'),
backButton = win.dom.querySelector('input.back'),
backFullButton = win.dom.querySelector('input.backFull')
;
nextButton?.addEventListener('click', () => {
win.custom._page += this.PAGINATION_LENGTH;
win.rebuild();
});
lastButton?.addEventListener('click', () => {
win.custom._page = -1;
win.rebuild();
});
if( backButton )
backButton.addEventListener('click', () => {
win.custom._page -= this.PAGINATION_LENGTH;
win.rebuild();
});
if( backFullButton )
backFullButton.addEventListener('click', () => {
win.custom._page = 0;
win.rebuild();
});
const parentWindow = win.parent;
if( parentWindow ){
// Parent is the same type as this, such as adding subconditions. You need to remove the parent from the list to prevent recursion.
if( type === parentWindow.type ){
const el = win.dom.querySelector('tr[data-id="'+parentWindow.id+'"]');
if( el )
el.remove();
}
}
// Checks if any of the checkboxes are checked
const markers = [...win.dom.querySelectorAll("input.marker")]; // Checkboxes
const checkBatchSelections = () => {
let checked = false;
for( let marker of markers ){
if( marker.checked ){
checked = true;
break;
}
}
batchDiv.classList.toggle("hidden", !checked);
};
// Delete asset
const del = elId => {
if( MOD.deleteAsset(type, elId) ){
DEV.closeAssetEditors(type, elId);
DEV.setDirty(true);
}
};
// If not linker, handle the batch selectors
if( !isLinker ){
checkBatchSelections();
batchDiv.querySelector('input.deleteSelected').onclick = event => {
const checkedLabels = [];
const markers = win.dom.querySelectorAll("input.marker:checked").values();
for( let marker of markers )
checkedLabels.push(marker.parentElement.parentElement.dataset.id);
if( !checkedLabels.length )
return;
if( confirm("Really delete "+checkedLabels.length+" items?") ){
for( let label of checkedLabels )
del(label);
win.rebuild();
this.rebuildAssetLists(type);
}
};
batchDiv.querySelector('input.exportSelected').onclick = event => {
const checkedLabels = [];
const markers = win.dom.querySelectorAll("input.marker:checked").values();
for( let marker of markers )
checkedLabels.push(marker.parentElement.parentElement.dataset.id);
if( !checkedLabels.length )
return;
let data = MOD.getExportData(baseObject.constructor, type, checkedLabels);
Window.create('export'+Generic.generateUUID(), 'jsonExport', 'Export Data - '+type, 'files', function(){
this.setDom('<textarea style="width:100%; height:100%; min-height:10vh">'+esc(JSON.stringify(data, undefined, 2), true)+'</textarea>');
}, undefined, undefined, data);
};
const checkAll = win.dom.querySelector('input.checkAll');
checkAll.onchange = event => {
for( let el of markers ){
if( !el.parentNode.parentNode.dataset.mod )
el.checked = !el.parentElement.parentElement.classList.contains("hidden") && checkAll.checked;
}
checkBatchSelections();
};
}
rows.forEach(el => {
// Library view
if( !isLinker ){
const base = el.querySelector("input[type=checkbox].marker");
base.parentElement.onclick = event => {
event.stopImmediatePropagation();
if( event.target === event.currentTarget )
base.checked = !base.checked;
checkBatchSelections();
};
}
// Linker view
// Bind click on row
el.addEventListener('click', event => {
const elId = event.currentTarget.dataset.id,
mod = event.currentTarget.dataset.mod,
ext = event.currentTarget.dataset.ext // This is an extend of another asset
;
// Ctrl deletes unless it's a linker
if( (!mod || ext) && !isLinker && (event.ctrlKey || event.metaKey) && confirm("Really delete?") ){
del(ext ? ext : elId);
win.rebuild();
this.rebuildAssetLists(type);
}
// If it's a linker you can use shift to bring up an editor
else if( event.shiftKey && (isLinker && mod) ){
DEV.buildAssetEditor(type, elId);
}
// Alt clones
else if( event.altKey ){
const asset = DEV.getAssetById(type, elId);
if( !asset )
throw 'Asset not found', type, elId;
this.insertCloneAsset(type, asset, baseObject.constructor, win, true);
}
// Unmodified non linker click opens
else if( !isLinker ){
DEV.buildAssetEditor(type, elId);
}
// This is a linker, we need to tie it to the parent
else{
if( !parentWindow )
throw 'Parent window missing';
// Get the asset we need to modify
// Linker expects the parent window to be an asset editor unles parentWindow.asset.asset is set
let baseAsset = parentWindow.asset.asset || MOD.getAssetById(parentWindow.type, parentWindow.id), // Window id is the asset ID for asset editors. Can only edit our mod, so get from that
targAsset = this.getAssetById(type, elId) // Target can be from a parent mod, so we'll need to include that in this search, which is why we use this instead of MOD
;
if( !baseAsset ){
console.error("Type", type, "parentWindow", parentWindow);
throw 'Base asset not found';
}
if( !targAsset )
throw 'Target asset not found';
// Handle subsets. This is pretty much just used for Collection type assets since they don't have their own database
let targ = win.id.split('::');
while( targ.length > 1 )
baseAsset = baseAsset[targ.shift()];
const key = targ.shift();
// win.id contains the field you're looking to link to
let label = targAsset.label || targAsset.id;
if( targAsset._e )
label = targAsset._e;
// Single assigns directly to the key
if( single ){
baseAsset[key] = label;
}
// Nonsingle appends to array
else{
if( !Array.isArray(baseAsset[key]) )
baseAsset[key] = [];
baseAsset[key].push(label);
}
win.close();
parentWindow.rebuild();
DEV.setDirty(true);
this.rebuildAssetLists(parentWindow.type);
this.propagateChange(win);
}
}, {
passive : true
});
});
win.dom.querySelector('input.new').onclick = event => {
const obj = baseObject.constructor.saveThis(baseObject, "mod");
this.insertAsset(type, obj, win);
};
// Search filter
const searchInput = win.dom.querySelector('input.search');
const performSearch = () => {
delete win._searchResults;
let searchTerm = searchInput.value.toLowerCase();
if( win._search !== searchTerm )
win.custom._page = 0;
win._search = searchTerm;
win.rebuild();
};
if( win._search )
searchInput.value = win._search;
let searchTimeout;
searchInput.onchange = event => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
performSearch();
}, 250);
};
searchInput.onkeypress = event => {
if( event.key === 'Enter' ){
clearTimeout(searchTimeout);
performSearch();
}
};
setTimeout(() => {
if( win === Window.front)
searchInput.focus();
}, 10)
},
// Takes an asset and tries to clone it, returns the cloned object
// Type: table in the mod, such as dungeonTemplate
// Asset: Asset to clone such as a DungeonTemplate
insertCloneAsset( type, asset, constructor, parentWindow, openEditor = true ){
const out = mod.mod.deepCloneAsset(type, constructor, asset);
// Cloning adds the whole object to our mod
delete out._e;
delete out._ext;
this.onInsertAsset(type, out, parentWindow, openEditor);
return out;
},
// mParent should be an object if supplied (see Mod.js for info about parented assets)
/*
type is the window type (needs to be listed in Modtools2.js
asset is the asset to insert
win is the parent window
openEditor is if you should open the editor immediately
windowData is custom data stored on the window. AVOID OBJECTS
*/
insertAsset( type, asset = {}, win, openEditor = true, windowData = '' ){
mod.mod[type].push(asset);
this.onInsertAsset(type, asset, win, openEditor, windowData);
},
onInsertAsset( type, asset, win, openEditor = true, windowData = '' ){
if( win?.editorOnCreate )
win.editorOnCreate(win, asset);
mod.setDirty(true);
if( openEditor )
mod.buildAssetEditor(type, asset.label || asset.id, undefined, win, windowData);
this.rebuildAssetLists(type);
},
// Rebuilds all listings by type
rebuildAssetLists( type ){
for( let win of Window.pages.values() ){
if(
(win.type === "Database" && win.id === type) || // This is a database listing of this type
(win.type === "linker" && win.asset && win.asset.targetType === type)
){
win.rebuild();
}
}
},
// Checks for an onChildChange method on win and any parents, and calls it
propagateChange( win ){
Window.getByType('Node Editor').forEach(el => el.onDbAssetChange(win.type, win.id));
let p = win;
while( true ){
if( typeof p.onChange === "function" )
p.onChange(win);
p = p.parent;
if( !p )
return;
}
},
// Tries to get an asset from our mod or a parent mod, tries to find id in label or actual id
getAssetById( type, id ){
let out = window.mod.mod.getAssetById(type, id);
if( out )
return out;
return window.mod.parentMod.getAssetById(type, id);
},
// Shared between editorDungeon and editorDungeonRoom
bindTestReverb( typeEl, wetEl, lowpassEl, buttonEl ){
buttonEl.onclick = async () => {
if( !this.TEST_AUDIO ){
await Audio.begin();
this.TEST_AUDIO = new Audio('test');
}
let wet = +wetEl.value || 1.0;
let lp = +lowpassEl.value || 1.0;
await this.TEST_AUDIO.setReverb(typeEl.value);
this.TEST_AUDIO.setWet(wet);
this.TEST_AUDIO.setLowpass(lp);
const sounds = ["trap_trigger", "tickle", "slap", "pinch", "chest_open"];
const audio = await this.TEST_AUDIO.play('/media/audio/'+randElem(sounds)+'.ogg');
};
},
helpLinkedList : 'This is a linked list. Click an item to edit it (provided it belongs to this mod), ctrl+click to remove an item. Use the arrows to move it up/down.',
// Player/PlayerTemplate JSON file parser
bindAdvancedArtLayers( win, asset, DB ){
win.dom.querySelector("div.istates").appendChild(EditorPlayerIconState.assetTable(win, asset, "istates"));
win.dom.querySelector("input.importAdvancedIcons").onchange = event => {
if( !event.target.files.length )
return;
const reader = new FileReader();
reader.onload = rEvt => {
event.target.value = "";
const json = JSON.parse(rEvt.target.result);
const data = json.layers;
const url = json.url || "";
if( !Array.isArray(data) )
throw 'Invalid JSON data. Not an array.';
if( win.dom.querySelector("input.replaceAll").checked ){
for( let istate of asset.istates ) {
window.mod.mod.deleteAsset('playerIconStates', istate);
}
asset.istates = [];
}
if( !Array.isArray(asset.istates) )
asset.istates = [];
const replaceIntoIstates = istate => {
if( !isNaN(istate.opacity) )
istate.opacity = Math.round(istate.opacity*100)/100;
const out = istate.save("mod");
out._mParent = {type : DB, label : asset.label};
window.mod.mod.mergeAsset("playerIconStates", out);
if( !asset.istates.includes(istate.id) )
asset.istates.push(istate.id);
window.mod.setDirty(true);
}
for( let istate of data ){
let icon = istate.icon;
if( url )
icon = url + (url.endsWith('/') || icon.startsWith('/') ? '' : '/') + icon;
istate.icon = icon;
replaceIntoIstates(new PlayerIconState(istate));
}
win.dom.querySelector("div.istates").replaceChildren(EditorPlayerIconState.assetTable(win, asset, "istates"));
};
reader.readAsText(event.target.files[0]);
};
win.dom.querySelector("input.exportAdvancedIcons").onclick = () => {
if( !Array.isArray(asset.istates) || !asset.istates.length )
return;
let outData = {
url : '',
layers : [],
};
const out = asset.istates.map(el => mod.mod.getAssetById('playerIconStates', el)).filter(el => Boolean(el));
// See if there's a common URL
for( let asset of out ){
// First element checks for a common URL
if( !outData.url ){
let url = asset.icon.split('/');
url.pop();
outData.url = url.join('/')+'/';
if( !outData.url )
break; // If there's no URL we can disregard this entirely.
continue;
}
// Now try to disprove that all start with the same URL
if( !asset.icon.startsWith(outData.url) ){
outData.url = '';
break;
}
}
if( outData.url ){
out.map(el => el.icon = el.icon.substring(outData.url.length)); // remove the URL frmo the icons
}
outData.layers = out;
const blob = new Blob([JSON.stringify(outData, null, 4)], { type: "text/json" });
const link = document.createElement("a");
link.download = asset.label+".json";
link.href = window.URL.createObjectURL(blob);
link.dataset.downloadurl = ["text/json", link.download, link.href].join(":");
const evt = new MouseEvent("click", {
view: window,
bubbles: true,
cancelable: true,
});
link.dispatchEvent(evt);
link.remove();
};
},
getAdvancedArtLayersForm(){
return 'Advanced icon states: <div class="istates"></div>'+
'<div class="labelFlex">'+
'<label>Import JSON <input type="file" class="importAdvancedIcons" accept=".json" /> Replace ALL: <input type="checkbox" checked class="replaceAll" /></label>'+
'<label><input type="button" value="Export JSON" class="exportAdvancedIcons" /></label>'+
'</div>';
},
};