implement texture atlas support for the stage editor

This commit is contained in:
Kolo
2026-07-24 04:01:50 +08:00
committed by Hundrec
parent 87fc681c56
commit e8d91e0f1f
10 changed files with 547 additions and 334 deletions
+1 -1
Submodule assets updated: aa81b51551...d9ebd43f6b
@@ -1,19 +1,22 @@
package funkin.ui.debug.stageeditor;
#if FEATURE_STAGE_EDITOR
import flixel.graphics.frames.FlxFramesCollection;
import funkin.data.animation.AnimationData;
import funkin.graphics.FunkinSprite;
import funkin.play.stage.Bopper;
import funkin.util.assets.FlxAnimationUtil;
import funkin.graphics.shaders.InverseDotsShader;
import funkin.ui.debug.stageeditor.StageEditorState.StageEditorAssetFile;
/**
* Contains all the Logic needed for Stage Editor. Only for Stage Editor, as in the gameplay StageProps and Boppers will be used.
*/
class StageEditorObject extends FunkinSprite
class StageEditorObject extends Bopper
{
/**
* The internal Name of the Object.
* The files used on this asset that will be saved later.
*/
public var name:String = "Unnamed";
public var usedFiles:Array<StageEditorAssetFile> = [];
public var selectedShader:InverseDotsShader;
@@ -22,7 +25,10 @@ class StageEditorObject extends FunkinSprite
*/
public var startingAnimation:String = "";
public var animDatas:Map<String, AnimationData> = [];
/**
* Stored list of animation data for easier exports.
*/
public var animationDatas:Map<String, AnimationData> = [];
override public function new()
{
@@ -32,6 +38,26 @@ class StageEditorObject extends FunkinSprite
shader = selectedShader;
}
override function set_frames(frames:FlxFramesCollection)
{
// Remove files associated with this sprite when resetting its frames.
usedFiles.resize(0);
// Since animations get reset when changing frames, we also have to reset the animation data stored.
animationDatas.clear();
startingAnimation = '';
return super.set_frames(frames);
}
override function destroy()
{
// Destroy the frame collection to prevent multiple texture atlas objects using the same frame collection instance.
this.frames.destroy();
super.destroy();
}
/**
* Whether the Object is currently being modified in the Stage Editor.
*/
@@ -41,85 +67,32 @@ class StageEditorObject extends FunkinSprite
{
this.isDebugged = value;
if (value == false) // plays upon starting yippee!!!
playAnim(startingAnimation, true);
if (!value)
{
update_shouldAlternate(); // Update the alternating animation now in case the animation list was changed.
playAnimation(startingAnimation, true);
}
else
{
if (animation.curAnim != null)
{
animation.stop();
offset.set();
updateHitbox();
}
if (animation.curAnim != null) animation.stop();
}
return value;
}
public function playAnim(name:String, restart:Bool = false, reversed:Bool = false):Void
public function addAnimation(data:AnimationData)
{
if (!animation.getNameList().contains(name)) return;
animation.play(name, restart, reversed, 0);
if (animDatas.exists(name)) offset.set(animDatas[name].offsets[0], animDatas[name].offsets[1]);
if (this.isAnimate)
{
FlxAnimationUtil.addTextureAtlasAnimation(this, data);
}
else
offset.set();
}
/**
* On which beat should it dance?
*/
public var danceEvery:Float = 0;
/**
* Internal, handles danceLeft and danceRight.
*/
var _danced:Bool = true;
public function dance(restart:Bool = false):Void
{
if (isDebugged) return;
var idle = animation.getNameList().contains("idle");
var dancing = animation.getNameList().contains("danceLeft") && animation.getNameList().contains("danceRight");
if (!idle && !dancing) return;
if (dancing)
{
if (_danced) playAnim("danceRight", restart);
else
playAnim("danceLeft", restart);
_danced = !_danced;
FlxAnimationUtil.addAtlasAnimation(this, data);
}
else if (idle)
{
playAnim("idle", restart);
}
}
public function addAnim(name:String, prefix:String, offsets:Array<Float>, indices:Array<Int>, frameRate:Int = 24, looped:Bool = true, flipX:Bool = false,
flipY:Bool = false)
{
if (indices.length > 0) animation.addByIndices(name, prefix, indices, "", frameRate, looped, flipX, flipY);
else
animation.addByPrefix(name, prefix, frameRate, looped, flipX, flipY);
if (animation.getNameList().contains(name)) // sometimes the animation doesnt add
{
animDatas.set(name, {
name: name,
prefix: prefix,
offsets: offsets,
looped: looped,
frameRate: frameRate,
flipX: flipX,
flipY: flipY,
frameIndices: indices
});
}
setAnimationOffsets(data.name, data.offsets[0], data.offsets[1]);
animationDatas.set(data.name, data);
}
}
#end
@@ -12,6 +12,7 @@ import flixel.FlxSprite;
import flixel.util.FlxColor;
import flixel.group.FlxGroup.FlxTypedGroup;
import flixel.addons.display.FlxGridOverlay;
import funkin.modding.events.ScriptEventDispatcher;
import funkin.play.character.BaseCharacter;
import funkin.play.character.BaseCharacter.CharacterType;
import funkin.data.character.CharacterData.CharacterDataParser;
@@ -51,6 +52,7 @@ import funkin.util.logging.CrashHandler;
import funkin.graphics.shaders.Grayscale;
import funkin.data.stage.StageRegistry;
import funkin.graphics.FunkinCamera;
import haxe.io.Bytes;
/**
* Da Stage Editor woo!!
@@ -77,7 +79,6 @@ class StageEditorState extends UIState
public static final MAX_Z_INDEX:Int = 10000;
public static final CHARACTER_COLORS:Array<FlxColor> = [FlxColor.RED, FlxColor.PURPLE, FlxColor.CYAN]; // FCUK IVE TURNED INTO AN AMERICAN
public static final TIME_BEFORE_ANIM_STOP:Float = 3.0;
public static var instance:StageEditorState = null; // unused lol
// the other shit:tm:
var menubar:MenuBar;
@@ -227,7 +228,22 @@ class StageEditorState extends UIState
public var charGroups:Map<CharacterType, FlxTypedGroup<BaseCharacter>> = [];
public var charCamOffsets:Map<CharacterType, Array<Float>> = DEFAULT_CAMERA_OFFSETS.copy();
public var charPos:Map<CharacterType, Array<Float>> = DEFAULT_POSITIONS.copy();
public var bitmaps:Map<String, BitmapData> = []; // used for optimizing the file size!!!
/**
* An array of files used by the sprites.
*/
public var allFiles(get, never):Array<StageEditorAssetFile>;
function get_allFiles()
{
var result:Array<StageEditorAssetFile> = [];
for (sprite in spriteArray)
{
result = result.concat(sprite.usedFiles);
}
return result.distinct();
}
var charDeselectShader:Grayscale = new Grayscale();
var floorLines:Array<FlxSprite> = [];
@@ -280,12 +296,9 @@ class StageEditorState extends UIState
override public function create():Void
{
WindowManager.instance.reset();
instance = this;
FlxG.sound.music?.stop();
WindowUtil.setWindowTitle("Friday Night Funkin\' Stage Editor");
AssetDataHandler.init(this);
camGame = new FunkinCamera();
camHUD = new FlxCamera();
camHUD.bgColor.alpha = 0;
@@ -420,7 +433,7 @@ class StageEditorState extends UIState
{
if (!allowInput || welcomeDialog != null) return;
var data = BitmapData.fromFile(path);
var data:Bytes = FileUtil.readBytesFromPath(path);
if (data != null)
{
@@ -534,22 +547,23 @@ class StageEditorState extends UIState
{
if (testingMode)
{
if (conductorInUse.currentBeat % 2 == 0)
{
for (char in getCharacters()) char?.dance(true);
}
for (asset in spriteArray)
{
if (asset.danceEvery > 0 && conductorInUse.currentBeat % asset.danceEvery == 0) asset.dance(true);
}
if (conductorInUse.currentBeat % 8 == 0 && !FlxG.keys.pressed.SHIFT) curTestChar++;
}
return super.beatHit();
}
override public function dispatchEvent(event:funkin.modding.events.ScriptEvent)
{
super.dispatchEvent(event);
// Dispatch the Conductor events to the stage props and characters to make them dance on beat in the testing mode.
if (!testingMode || !(event is funkin.modding.events.ScriptEvent.SongTimeScriptEvent)) return;
for (prop in spriteArray) ScriptEventDispatcher.callEvent(prop, event);
for (char in getCharacters()) ScriptEventDispatcher.callEvent(char, event);
}
override public function update(elapsed:Float):Void
{
// Save the stage if exiting through the F4 keybind, as it moves you to the Main Menu.
@@ -1508,25 +1522,27 @@ class StageEditorState extends UIState
case 'copy object':
if (selectedSprite == null) return;
copiedSprite = selectedSprite.toData(true);
copiedSprite = selectedSprite.toData();
case 'paste object':
if (copiedSprite == null) return;
saved = false;
var spr = new StageEditorObject().fromData(copiedSprite);
var objNames = [for (a in spriteArray) a.name];
if (objNames.contains(spr.name))
// Change the sprite name here instead of later since the texture atlas frame collections are saved by the prop name.
var dataCopy:Dynamic = Reflect.copy(copiedSprite);
var objNames:Array<String> = [for (a in spriteArray) a.name];
if (objNames.contains(dataCopy.name))
{
var i = 1;
while (objNames.contains(spr.name + ' (' + i + ')'))
while (objNames.contains(dataCopy.name + ' (' + i + ')'))
i++;
spr.name += ' (' + i + ')';
dataCopy.name += ' (' + i + ')';
}
var spr:StageEditorObject = new StageEditorObject().fromData(dataCopy);
add(spr);
selectedSprite = spr;
updateArray();
@@ -1598,48 +1614,30 @@ class StageEditorState extends UIState
undoArray = [];
redoArray = [];
updateArray();
removeUnusedBitmaps();
allFiles.resize(0);
}
public function removeUnusedBitmaps()
/**
* Get a stored file based on the name or the data.
*/
public function getFile(?name:String, ?data:Bytes)
{
var usedBitmaps:Array<String> = [];
for (asset in spriteArray)
for (file in allFiles)
{
var data = asset.toData(false);
if (data.assetPath.startsWith('#')) continue; // the simple graphics
usedBitmaps.push(data.assetPath);
if (data != null && file.data == data) return file;
if (name != null && file.name == name) return file;
}
for (name => bit in bitmaps)
{
if (usedBitmaps.contains(name)) continue;
bitmaps.remove(name);
}
return null;
}
public function addBitmap(newBitmap:BitmapData, ?name:String):String
/**
* Creates a file to be used for the assets.
*/
public function createFile(name:String, data:Bytes)
{
// first we check for existing bitmaps so we dont like add an extra one
for (name => bitmap in bitmaps)
{
if (bitmap == newBitmap) return name;
}
if (name != null && !bitmaps.exists(name))
{
bitmaps.set(name, newBitmap);
return name;
}
var id:Int = 0;
while (bitmaps.exists('image' + id))
id++;
bitmaps.set('image' + id, newBitmap);
return 'image' + id;
if (getFile(name, data) != null) return getFile(name, data);
return {name: name, data: data};
}
override function destroy():Void
@@ -1745,3 +1743,16 @@ typedef StageEditorParams =
*/
var ?targetDadChar:String;
};
typedef StageEditorAssetFile =
{
/**
* The name of the file.
*/
var name:String;
/**
* The content of the file, decoded into bytes.
*/
var data:Bytes;
}
@@ -3,7 +3,9 @@ package funkin.ui.debug.stageeditor.components;
#if FEATURE_STAGE_EDITOR
import haxe.ui.containers.dialogs.Dialog;
import funkin.ui.debug.stageeditor.handlers.AssetDataHandler;
import funkin.ui.debug.stageeditor.StageEditorState.StageEditorAssetFile;
import openfl.display.BitmapData;
import haxe.io.Bytes;
import haxe.ui.notifications.NotificationType;
import haxe.ui.notifications.NotificationManager;
@@ -13,14 +15,14 @@ class NewObjDialog extends Dialog
public var bitmapName:Null<String> = null;
var stageEditorState:StageEditorState;
var bitmap:BitmapData;
var data:Bytes;
override public function new(state:StageEditorState, img:BitmapData = null)
override public function new(state:StageEditorState, data:Bytes = null)
{
super();
stageEditorState = state;
bitmap = img;
this.data = data;
field.onChange = function(_)
{
@@ -39,8 +41,8 @@ class NewObjDialog extends Dialog
if (button == '{{Create}}')
{
var objNames = [
for (obj in StageEditorState.instance.spriteArray) obj.name
var objNames:Array<String> = [
for (obj in stageEditorState.spriteArray) obj.name
];
if (field.text == '' || field.text == null || objNames.contains(field.text))
@@ -57,10 +59,12 @@ class NewObjDialog extends Dialog
{
var spr = new StageEditorObject();
if (bitmap != null)
if (data != null)
{
var bitToLoad = stageEditorState.addBitmap(bitmap, bitmapName);
spr.loadGraphic(stageEditorState.bitmaps[bitToLoad]);
var file:StageEditorAssetFile = stageEditorState.createFile(bitmapName, data);
spr.loadGraphic(BitmapData.fromBytes(file.data));
spr.usedFiles.push(file);
}
else
spr.loadGraphic(AssetDataHandler.getDefaultGraphic());
@@ -68,7 +72,7 @@ class NewObjDialog extends Dialog
spr.name = field.text;
spr.screenCenter();
var sprArray = stageEditorState.spriteArray;
var sprArray:Array<StageEditorObject> = stageEditorState.spriteArray;
spr.zIndex = sprArray.length == 0 ? 0 : (sprArray[sprArray.length - 1].zIndex + 1);
stageEditorState.selectedSprite = spr;
@@ -0,0 +1,109 @@
package funkin.ui.debug.stageeditor.components;
#if FEATURE_STAGE_EDITOR
import animate.FlxAnimateFrames;
import funkin.util.FileUtil;
import funkin.util.SerializerUtil;
import haxe.io.Bytes;
import haxe.io.Path;
import haxe.ui.containers.dialogs.Dialog;
import haxe.ui.components.CheckBox;
import haxe.ui.components.DropDown;
import openfl.display.BitmapData;
@:build(haxe.ui.macros.ComponentMacros.build('assets/exclude/data/ui/stage-editor/dialogs/texture-atlas-settings.xml'))
class TextureAtlasSettingsDialog extends Dialog
{
var atlasPath:String;
var stageEditorState:StageEditorState;
var taSwfMode:CheckBox;
var taCacheOnLoad:CheckBox;
var taFilterQuality:DropDown;
var taApplyStageMatrix:CheckBox;
var taUseRenderTexture:CheckBox;
override public function new(state:StageEditorState, atlasPath:String)
{
super();
stageEditorState = state;
this.atlasPath = atlasPath;
buttons = DialogButton.CANCEL | DialogButton.APPLY;
destroyOnClose = true;
}
override public function validateDialog(button:DialogButton, fn:Bool->Void)
{
var done:Bool = true;
if (button == '{{apply}}')
{
var linkedObj:StageEditorObject = stageEditorState.selectedSprite;
var files:Array<String> = FileUtil.readDir(atlasPath);
var baseAtlasPath:String = Path.withoutDirectory(atlasPath);
var animJson:String = FileUtil.readStringFromPath(Path.join([atlasPath, 'Animation.json']));
var spritemapFiles:Array<StageEditorState.StageEditorAssetFile> = [];
// Texture atlases could have multiple spritemaps so we have to locate and load them all.
var i:Int = 1;
while (files.containsAllExact(['spritemap$i.png', 'spritemap$i.json']))
{
var joinedPath:String = Path.join([baseAtlasPath, 'spritemap$i']);
spritemapFiles.push({name: '$joinedPath.png', data: FileUtil.readBytesFromPath(Path.join([atlasPath, 'spritemap$i.png']))});
spritemapFiles.push({name: '$joinedPath.json', data: FileUtil.readBytesFromPath(Path.join([atlasPath, 'spritemap$i.json']))});
i++;
}
@:privateAccess
var spritemaps:Array<SpritemapInput> = [
for (i in 0...Std.int(spritemapFiles.length / 2)) {
source: BitmapData.fromBytes(spritemapFiles[i * 2].data),
json: SerializerUtil.sanitizeJSON(spritemapFiles[i * 2 + 1].data.toString())
}
];
linkedObj.applyStageMatrix = taApplyStageMatrix.selected;
linkedObj.useRenderTexture = taUseRenderTexture.selected;
var settings:FlxAnimateSettings = {
swfMode: taSwfMode.selected,
cacheOnLoad: taCacheOnLoad.selected,
filterQuality: cast taFilterQuality.selectedIndex
};
linkedObj.frames = FlxAnimateFrames.fromAnimate(animJson, spritemaps, null, linkedObj.name, true, settings);
@:privateAccess
cast(linkedObj.frames, FlxAnimateFrames)._settings = settings; // Settings are temporary, but we need to save them for exporting.
// Unlike sparrow/packer assets, whose spritesheets can be loaded even when there's no animations attached to the sprite,
// texture atlas objects need to have at least one animation before they could properly load, as boppers (animated assets)
// are only created if an object's `danceEvery` isn't zero or the object has animations.
var baseSymbol:String = cast(linkedObj.frames, FlxAnimateFrames).timeline.libraryItem.name;
linkedObj.addAnimation({
name: 'Idle',
prefix: baseSymbol,
animType: 'symbol',
offsets: [0, 0],
frameIndices: [],
looped: false,
flipX: false,
flipY: false,
frameRate: Std.int(linkedObj.anim.getDefaultFramerate())
});
linkedObj.startingAnimation = 'Idle';
linkedObj.usedFiles.push({name: Path.join([baseAtlasPath, 'Animation.json']), data: Bytes.ofString(animJson)});
linkedObj.usedFiles = linkedObj.usedFiles.concat(spritemapFiles);
stageEditorState.notifyChange('Texture Atlas Loading Done', 'Finished loading the Texture Atlas for the object ${linkedObj.name}.');
stageEditorState.updateDialog(OBJECT_ANIMS);
}
fn(done);
}
}
#end
@@ -1,12 +1,14 @@
package funkin.ui.debug.stageeditor.handlers;
#if FEATURE_STAGE_EDITOR
import animate.FlxAnimateFrames;
import flixel.graphics.frames.FlxAtlasFrames;
import openfl.display.BitmapData;
import flixel.FlxSprite;
import flixel.util.FlxColor;
import openfl.display.BlendMode;
import flixel.FlxSprite;
import funkin.data.stage.StageData.StageDataProp;
import haxe.io.Path;
import openfl.display.BitmapData;
import openfl.display.BlendMode;
using StringTools;
@@ -15,24 +17,17 @@ using StringTools;
*/
class AssetDataHandler
{
static var state:StageEditorState;
public static function init(state:StageEditorState)
{
AssetDataHandler.state = state;
}
/**
* Turns an Object into Data.
* @param obj the Object whose data to read.
* @param useBitmaps Whether to Save object's BitmapData directly.
* @return Data of the Object
*/
public static function toData(obj:StageEditorObject, useBitmaps:Bool = false):StageEditorObjectData
public static function toData(obj:StageEditorObject):StageEditorObjectData
{
var outputData:StageEditorObjectData = {
name: obj.name,
assetPath: "",
assetPath: '',
position: [obj.x, obj.y],
zIndex: obj.zIndex,
isPixel: !obj.antialiasing,
@@ -40,35 +35,46 @@ class AssetDataHandler
alpha: obj.alpha,
danceEvery: obj.animation.getNameList().length > 0 ? obj.danceEvery : 0,
scroll: [obj.scrollFactor.x, obj.scrollFactor.y],
animations: [for (n => d in obj.animDatas) d],
animations: [
for (n => d in obj.animationDatas) d
],
startingAnimation: obj.startingAnimation,
animType: "sparrow", // automatically making sparrow atlases yeah
animType: getAnimType(obj),
angle: obj.angle,
flipX: obj.flipX,
flipY: obj.flipY,
blend: obj.blend == null ? "" : Std.string(obj.blend),
color: obj.color.toWebString(),
animData: ""
neededFiles: obj.usedFiles.clone()
}
if (useBitmaps)
if (obj.usedFiles.length > 0)
{
outputData.bitmap = obj.pixels.clone();
outputData.animData = obj.generateXML();
return outputData;
}
for (name => bit in state.bitmaps)
{
if (areTheseBitmapsEqual(bit, obj.pixels))
if (outputData.animType == 'animateatlas')
{
outputData.assetPath = name;
outputData.animData = obj.generateXML(name);
return outputData;
outputData.assetPath = Path.directory(obj.usedFiles[0].name);
// Additionally set the atlas settings here.
var frames:FlxAnimateFrames = cast obj.frames;
@:privateAccess
outputData.atlasSettings = {
useRenderTexture: obj.useRenderTexture,
applyStageMatrix: obj.applyStageMatrix,
swfMode: frames._settings?.swfMode ?? false,
cacheOnLoad: frames._settings?.cacheOnLoad ?? false,
filterQuality: frames._settings?.filterQuality ?? animate.FlxAnimateFrames.FilterQuality.MEDIUM
}
}
else
{
outputData.assetPath = Path.withoutExtension(obj.usedFiles[0].name);
}
}
outputData.assetPath = "#FFFFFF";
else // Solid color
{
outputData.assetPath = obj.color.toWebString();
}
return outputData;
}
@@ -80,44 +86,68 @@ class AssetDataHandler
*/
public static function fromData(object:StageEditorObject, data:StageEditorObjectData)
{
if (data.bitmap != null)
if (data.animations != null && data.animations.length > 0)
{
if (data.animations != null && data.animations.length > 0)
switch (data.animType)
{
var bitToLoad = state.addBitmap(data.bitmap.clone(), data.name);
object.frames = FlxAtlasFrames.fromSparrow(state.bitmaps[bitToLoad], data.animData);
}
else if (areTheseBitmapsEqual(data.bitmap, getDefaultGraphic()))
{
object.loadGraphic(getDefaultGraphic());
}
else
{
var bitToLoad = state.addBitmap(data.bitmap.clone(), data.name);
object.loadGraphic(state.bitmaps[bitToLoad]);
case 'sparrow':
var spritesheet:Null<StageEditorState.StageEditorAssetFile> = data.neededFiles.find(f -> f.name.endsWith('.png'));
var frameData:Null<StageEditorState.StageEditorAssetFile> = data.neededFiles.find(f -> f.name.endsWith('.xml'));
if (spritesheet != null && frameData != null)
{
object.frames = FlxAtlasFrames.fromSparrow(BitmapData.fromBytes(spritesheet.data), frameData.data.toString());
}
case 'packer':
var spritesheet:Null<StageEditorState.StageEditorAssetFile> = data.neededFiles.find(f -> f.name.endsWith('.png'));
var frameData:Null<StageEditorState.StageEditorAssetFile> = data.neededFiles.find(f -> f.name.endsWith('.txt'));
if (spritesheet != null && frameData != null)
{
object.frames = FlxAtlasFrames.fromSpriteSheetPacker(BitmapData.fromBytes(spritesheet.data), frameData.data.toString());
}
case 'animateatlas':
var animateJson:String = data.neededFiles.find(f -> f.name.endsWith('/Animation.json'))?.data?.toString() ?? '';
var spritemaps:Array<SpritemapInput> = [];
var totalSpritemaps:Int = Std.int((data.neededFiles.length - 1) / 2);
for (i in 0...totalSpritemaps)
{
var name:String = '/spritemap${i + 1}';
spritemaps.push({
source: BitmapData.fromBytes(data.neededFiles.find(f -> f.name.endsWith('$name.png'))?.data),
json: data.neededFiles.find(f -> f.name.endsWith('$name.json'))?.data?.toString() ?? ''
});
}
object.applyStageMatrix = data.atlasSettings?.applyStageMatrix ?? false;
object.useRenderTexture = data.atlasSettings?.useRenderTexture ?? false;
var settings:FlxAnimateSettings = {
swfMode: data.atlasSettings?.swfMode ?? false,
cacheOnLoad: data.atlasSettings?.cacheOnLoad ?? false,
filterQuality: cast data.atlasSettings?.filterQuality ?? animate.FlxAnimateFrames.FilterQuality.MEDIUM
};
object.frames = FlxAnimateFrames.fromAnimate(animateJson, spritemaps, null, data.name, true, settings);
@:privateAccess
cast(object.frames, FlxAnimateFrames)._settings = settings; // Settings are temporary, but we need to save them for exporting.
default:
// Do nothing.
}
}
else if (data.assetPath.startsWith("#"))
{
object.loadGraphic(getDefaultGraphic());
object.color = FlxColor.fromString(data.assetPath);
}
else
{
if (data.animations != null && data.animations.length > 0) // considering we're unpacking we might as well just do this instead of switch
{
if (data.animData.contains("</TextureAtlas>"))
{
object.frames = FlxAtlasFrames.fromSparrow(state.bitmaps[data.assetPath].clone(), data.animData);
}
else
{
object.frames = FlxAtlasFrames.fromSpriteSheetPacker(state.bitmaps[data.assetPath].clone(), data.animData);
}
}
else if (data.assetPath.startsWith("#"))
{
object.loadGraphic(getDefaultGraphic());
object.color = FlxColor.fromString(data.assetPath);
}
else
object.loadGraphic(state.bitmaps[data.assetPath].clone());
}
object.loadGraphic(BitmapData.fromBytes(data.neededFiles[0].data));
object.usedFiles = data.neededFiles;
object.name = data.name;
object.setPosition(data.position[0], data.position[1]);
@@ -126,18 +156,26 @@ class AssetDataHandler
object.alpha = data.alpha;
object.danceEvery = data.danceEvery;
object.scrollFactor.set(data.scroll[0], data.scroll[1]);
object.startingAnimation = data.startingAnimation;
object.startingAnimation = data.startingAnimation ?? "";
object.angle = data.angle;
object.blend = blendFromString(data.blend);
if (!data.assetPath.startsWith("#")) object.color = FlxColor.fromString(data.color);
for (anim in data.animations)
{
object.addAnim(anim.name, anim.prefix, anim.offsets ?? [0, 0], anim.frameIndices ?? [], anim.frameRate ?? 24, anim.looped ?? false, anim.flipX ?? false,
anim.flipY ?? false);
object.addAnimation(anim);
}
if (object.animation.getNameList().contains(data.startingAnimation)) object.startingAnimation = data.startingAnimation;
if (object.animation.getNameList().contains(data.startingAnimation))
{
object.startingAnimation = data.startingAnimation;
object.playAnimation(object.startingAnimation);
flixel.util.FlxTimer.wait(StageEditorState.TIME_BEFORE_ANIM_STOP, function()
{
if (object?.animation?.curAnim != null) object.animation.stop();
});
}
switch (data.scale)
{
@@ -149,16 +187,25 @@ class AssetDataHandler
}
object.updateHitbox();
object.playAnim(object.startingAnimation);
flixel.util.FlxTimer.wait(StageEditorState.TIME_BEFORE_ANIM_STOP, function()
{
if (object?.animation?.curAnim != null) object.animation.stop();
});
return object;
}
/**
* Get the animation type of an object depending on the frames and animation count.
*/
public static function getAnimType(object:StageEditorObject)
{
if (object.isAnimate) return 'animateatlas';
// For packer we check every file for the object to see if it has a .txt file.
for (file in object.usedFiles)
{
if (file.name.endsWith(".txt")) return 'packer';
}
return 'sparrow'; // Sparrow is the default value, even for static sprites.
}
/**
* Returns a default BitmapData to be used for all the props.
* @return BitmapData
@@ -175,7 +222,6 @@ class AssetDataHandler
*/
public static function blendFromString(blend:String):BlendMode
{
// originally this was a MASSIVE and I do mean MASSIVE switch case, though then I found out that blendmode already has one implemented
@:privateAccess
return BlendMode.fromString(blend.toLowerCase().trim());
}
@@ -199,32 +245,11 @@ class AssetDataHandler
xml += "</TextureAtlas>";
return xml;
}
// I am aware OpenFL has it's own compare bitmap function, though I find this to be better ngl
static function areTheseBitmapsEqual(bitmap1:BitmapData, bitmap2:BitmapData)
{
if (bitmap1.width != bitmap2.width || bitmap1.height != bitmap2.height) return false;
var bytes1 = bitmap1.image.data;
var bytes2 = bitmap2.image.data;
for (i in 0...bytes1.length)
{
if (bytes1[i] != bytes2[i])
{
return false;
}
}
return true;
}
}
typedef StageEditorObjectData =
{
> StageDataProp,
var animData:String;
var ?bitmap:BitmapData;
var neededFiles:Array<StageEditorState.StageEditorAssetFile>;
}
#end
@@ -10,8 +10,10 @@ import funkin.play.character.BaseCharacter;
import funkin.data.stage.StageData;
import funkin.data.stage.StageData.StageDataCharacter;
import funkin.data.stage.StageRegistry;
import funkin.ui.debug.stageeditor.StageEditorState.StageEditorAssetFile;
import openfl.utils.Assets as OpenFLAssets;
import lime.utils.Assets as LimeAssets;
import haxe.io.Path;
using StringTools;
@@ -28,11 +30,9 @@ class StageDataHandler
endData.directory = state.stageFolder;
// step 1 phase 1: object data
var xmlMap:Map<String, String> = [];
for (obj in state.spriteArray)
{
var data = obj.toData(false);
var data = obj.toData();
endData.props.push({
name: data.name,
assetPath: data.assetPath.startsWith("#") ? data.color : data.assetPath,
@@ -50,10 +50,9 @@ class StageDataHandler
flipY: data.flipY,
angle: data.angle,
blend: data.blend,
color: data.assetPath.startsWith("#") ? "#FFFFFF" : data.color
color: data.assetPath.startsWith("#") ? "#FFFFFF" : data.color,
atlasSettings: data.atlasSettings
});
if (!xmlMap.exists(data.assetPath) && data.animData != "") xmlMap.set(data.assetPath, data.animData);
}
// step 1 phase 2: character data
@@ -99,45 +98,23 @@ class StageDataHandler
// step 2: saving everything to entryList
var entryList = new Array<Entry>();
// step 2 phase 1: images
state.removeUnusedBitmaps();
for (name => img in state.bitmaps)
// step 2 phase 1: assets
for (asset in state.allFiles)
{
var bytes = img?.image?.encode(PNG);
if (bytes == null) continue;
var entry:Entry = {
fileName: name + ".png",
fileSize: bytes.length,
fileName: asset.name,
fileSize: asset.data.length,
fileTime: Date.now(),
compressed: false,
dataSize: bytes.length,
data: bytes,
crc32: null // apparently fileutil.hx does not like crc32, idk why but i dont even know what crc32 is
}
entryList.push(entry);
}
// step 2 phase 2: xmls
for (path => xml in xmlMap)
{
var bytes = Bytes.ofString(xml);
var entry:Entry = {
fileName: path + ".xml",
fileSize: bytes.length,
fileTime: Date.now(),
compressed: false,
dataSize: bytes.length,
data: bytes,
dataSize: asset.data.length,
data: asset.data,
crc32: null
}
entryList.push(entry);
}
// step 2 phase 3: the main data
// step 2 phase 2: the main data
var stageBytes = Bytes.ofString(endData.serialize());
entryList.push({
fileName: formatStageId(endData.name) + ".json",
@@ -156,28 +133,24 @@ class StageDataHandler
public static function unpackShitFromZip(state:StageEditorState, zip:Bytes)
{
state.clearAssets();
state.bitmaps.clear();
var entries = FileUtil.readZIPFromBytes(zip);
var stageData:StageData = new StageData();
var xmls:Map<String, String> = [];
var entries:Array<Entry> = FileUtil.readZIPFromBytes(zip);
var stageData:Null<StageData> = null;
var allFiles:Array<StageEditorAssetFile> = [];
for (stuff in entries)
{
var ext = stuff.fileName.split(".")[1];
var ext:String = stuff.fileName.split(".")[1];
switch (ext)
// A json file can either be a texture atlas file or the stage data.
// Texture atlas files are found in a folder with other atlas assets, so the stage data doesn't have a slash in the name
if (ext == "json" && !stuff.fileName.contains("/"))
{
case "png":
var data = BitmapData.fromBytes(stuff.data);
state.bitmaps.set(stuff.fileName.replace(".png", ""), data);
case "xml":
xmls.set(stuff.fileName.replace(".xml", ""), stuff.data.toString());
case "json":
stageData = StageRegistry.instance.parseEntryDataRaw(stuff.data.toString(), stuff.fileName);
stageData = StageRegistry.instance.parseEntryDataRaw(stuff.data.toString(), stuff.fileName);
}
else
{
allFiles.push({name: stuff.fileName, data: stuff.data});
}
}
@@ -199,12 +172,14 @@ class StageDataHandler
// objects
for (objData in stageData.props)
{
// make the data and roll with it
var assets:Array<StageEditorAssetFile> = allFiles.filter((f) -> Path.withoutExtension(f.name) == objData.assetPath);
var spr = new StageEditorObject();
spr.fromData({
name: objData.name ?? "Unnamed",
assetPath: objData.assetPath,
animations: objData.animations.copy(),
animType: objData.animType,
scale: objData.scale,
position: objData.position,
alpha: objData.alpha,
@@ -218,7 +193,8 @@ class StageDataHandler
flipX: objData.flipX,
flipY: objData.flipY,
startingAnimation: objData.startingAnimation,
animData: xmls[objData.assetPath] ?? ""
neededFiles: assets,
atlasSettings: objData.atlasSettings
});
state.add(spr);
@@ -271,15 +247,8 @@ class StageDataHandler
public static function loadFromDataRaw(state:StageEditorState, data:StageData)
{
state.clearAssets();
state.bitmaps.clear();
if (data == null)
{
loadDummyData(state);
return;
}
@:privateAccess
if (!LimeAssets.libraryPaths.exists(data.directory))
if (data == null || !LimeAssets.libraryPaths.exists(data.directory))
{
loadDummyData(state);
return;
@@ -305,16 +274,39 @@ class StageDataHandler
for (objData in data.props)
{
var spr = new StageEditorObject();
if (!objData.assetPath.startsWith("#")) state.bitmaps.set(objData.assetPath, Assets.getBitmapData(Paths.image(objData.assetPath)));
var neededFiles:Array<StageEditorAssetFile> = [];
var usePacker:Bool = objData.animType == "packer";
var animPath:String = Paths.file("images/" + objData.assetPath + (usePacker ? ".txt" : ".xml"));
var animText:String = Assets.exists(animPath) ? Assets.getText(animPath) : "";
// Add all assets related to the object to the allFiles array.
if (!objData.assetPath.startsWith("#"))
{
if (objData.animType == "animateatlas")
{
var checkFor:String = Paths.stripLibrary(Paths.animateAtlas(objData.assetPath, state.stageFolder));
for (file in Assets.list())
{
if (!file.startsWith(checkFor)) continue;
var validName:String = objData.assetPath + file.substring(checkFor.length);
neededFiles.push(state.createFile(validName, Assets.getBytes('${state.stageFolder}:$file')));
}
}
else
{
neededFiles.push(state.createFile('${objData.assetPath}.png', Assets.getBytes(Paths.image(objData.assetPath))));
var animFile:String = '${objData.assetPath}${objData.animType == "packer" ? ".txt" : ".xml"}';
if (Assets.exists(Paths.file('images/$animFile')))
{
neededFiles.push(state.createFile(animFile, Assets.getBytes(Paths.file('images/$animFile'))));
}
}
}
spr.fromData({
name: objData.name ?? "Unnamed",
assetPath: objData.assetPath,
animations: objData.animations.copy(),
animType: objData.animType,
scale: objData.scale,
position: objData.position,
alpha: objData.alpha,
@@ -328,13 +320,14 @@ class StageDataHandler
flipX: objData.flipX,
flipY: objData.flipY,
startingAnimation: objData.startingAnimation,
animData: animText
neededFiles: neededFiles,
atlasSettings: objData.atlasSettings
});
state.add(spr);
state.updateArray();
}
state.updateArray();
state.updateMarkerPos();
}
@@ -138,7 +138,7 @@ class UndoRedoHandler
case OBJECT_DELETED:
finalAction.data = {
ID: state.selectedSprite.ID,
data: state.selectedSprite.toData(true)
data: state.selectedSprite.toData()
}
case OBJECT_ROTATED:
@@ -1,6 +1,7 @@
package funkin.ui.debug.stageeditor.toolboxes;
#if FEATURE_STAGE_EDITOR
import animate.FlxAnimateFrames;
import haxe.ui.components.Button;
import haxe.ui.components.CheckBox;
import haxe.ui.components.DropDown;
@@ -40,11 +41,18 @@ class StageEditorObjectAnimsToolbox extends StageEditorDefaultToolbox
{
if (objFrameList.selectedIndex == -1) return;
objAnimPrefix.text = objFrameList.selectedItem.name;
// Remove the indicators from the prefix text if the selected item is from an atlas sprite.
if (objFrameList.selectedItem.isLabel != null)
{
var isLabel:Bool = objFrameList.selectedItem.isLabel ?? false;
objAnimPrefix.text = objAnimPrefix.text.replace(' ${isLabel ? "(Label)" : "(Symbol)"}', '');
}
}
objAnims.onChange = function(_)
{
var animData = linkedObj?.animDatas[
var animData = linkedObj?.animationDatas[
objAnims.selectedItem?.text ?? ""
];
@@ -104,8 +112,7 @@ class StageEditorObjectAnimsToolbox extends StageEditorDefaultToolbox
if (linkedObj.startingAnimation == daAnim) linkedObj.startingAnimation = "";
linkedObj.animation.remove(daAnim);
linkedObj.animDatas.remove(daAnim);
linkedObj.offset.set();
linkedObj.animationDatas.remove(daAnim);
state.notifyChange("Animation Deletion Done", "Animation "
+ objAnims.selectedItem.text
@@ -143,13 +150,30 @@ class StageEditorObjectAnimsToolbox extends StageEditorDefaultToolbox
}
// Otherwise, update them accordingly.
if (previousFrames !=[
for (f in linkedObj.frames.frames) f.name
]) updateFrameList();
if (previousFrames != getObjectFrameList()) updateFrameList();
if (previousAnims != linkedObj.animation.getNameList().copy()) updateAnimList();
}
/**
* Get a formatted list of frames for the object.
*/
function getObjectFrameList():Array<String>
{
if (linkedObj == null) return [];
if (linkedObj.frames is FlxAnimateFrames)
{
var frames:FlxAnimateFrames = cast linkedObj.frames;
@:privateAccess
return linkedObj.getFrameLabelList().concat(frames.dictionary.keys().array());
}
return[
for (f in linkedObj.frames.frames) f.name
];
}
function updateFrameList()
{
previousFrames = [];
@@ -157,11 +181,30 @@ class StageEditorObjectAnimsToolbox extends StageEditorDefaultToolbox
if (linkedObj == null) return;
for (fname in linkedObj.frames.frames)
if (linkedObj.frames is FlxAnimateFrames)
{
if (fname != null) objFrameList.dataSource.add({name: fname.name});
for (fname in linkedObj.getFrameLabelList())
{
objFrameList.dataSource.add({name: '$fname (Label)', isLabel: true});
previousFrames.push(fname);
}
previousFrames.push(fname.name);
var frames:FlxAnimateFrames = cast linkedObj.frames;
@:privateAccess
for (fname in frames.dictionary.keys())
{
objFrameList.dataSource.add({name: '$fname (Symbol)', isLabel: false});
previousFrames.push(fname);
}
}
else
{
for (fname in linkedObj.frames.frames)
{
if (fname != null) objFrameList.dataSource.add({name: fname.name});
previousFrames.push(fname.name);
}
}
}
@@ -198,9 +241,24 @@ class StageEditorObjectAnimsToolbox extends StageEditorDefaultToolbox
}
var shouldDoIndices:Bool = (indices.length > 0 && !indices.contains(null));
var isSymbol:Bool = false;
linkedObj.addAnim(objAnimName.text, objAnimPrefix.text, [objAnimOffsetX.pos, objAnimOffsetY.pos], (shouldDoIndices ? indices : []),
Std.int(objAnimFramerate.pos), objAnimLooped.selected, objAnimFlipX.selected, objAnimFlipY.selected);
if (linkedObj.frames is FlxAnimateFrames)
{
isSymbol = cast(linkedObj.frames, FlxAnimateFrames).existsSymbol(objAnimPrefix.text);
}
linkedObj.addAnimation({
name: objAnimName.text,
prefix: objAnimPrefix.text,
offsets: [objAnimOffsetX.pos, objAnimOffsetY.pos],
looped: objAnimLooped.selected,
frameIndices: (shouldDoIndices ? indices : []),
frameRate: Std.int(objAnimFramerate.pos),
flipX: objAnimFlipX.selected,
flipY: objAnimFlipY.selected,
animType: isSymbol ? 'symbol' : 'framelabel'
});
if (linkedObj.animation.getByName(objAnimName.text) == null)
{
@@ -209,7 +267,7 @@ class StageEditorObjectAnimsToolbox extends StageEditorDefaultToolbox
}
if (objAnimStart.selected) linkedObj.startingAnimation = objAnimName.text;
linkedObj.playAnim(objAnimName.text);
linkedObj.playAnimation(objAnimName.text);
stageEditorState.notifyChange("Animation Saving Done", "Animation " + objAnimName.text + " has been saved to the Object " + linkedObj.name + ".");
updateAnimList();
@@ -3,13 +3,15 @@ package funkin.ui.debug.stageeditor.toolboxes;
#if FEATURE_STAGE_EDITOR
import flixel.graphics.frames.FlxAtlasFrames;
import funkin.ui.debug.stageeditor.handlers.AssetDataHandler;
import funkin.ui.debug.stageeditor.StageEditorState.StageEditorAssetFile;
import funkin.ui.debug.stageeditor.components.TextureAtlasSettingsDialog;
import funkin.util.FileUtil;
import haxe.io.Bytes;
import haxe.io.Path;
import haxe.ui.components.Button;
import haxe.ui.components.Image;
import haxe.ui.components.NumberStepper;
import haxe.ui.components.TextArea;
import haxe.ui.containers.dialogs.Dialogs.FileDialogTypes;
import haxe.ui.containers.dialogs.Dialogs;
import haxe.ui.ToolkitAssets;
import openfl.display.BitmapData;
import haxe.ui.events.UIEvent;
@@ -21,6 +23,7 @@ class StageEditorObjectGraphicToolbox extends StageEditorDefaultToolbox
var objImage:Image;
var objLoad:Button;
var objLoadNet:Button;
var objLoadTextureAtlas:Button;
var objReset:Button;
var objResetFrames:Button;
var objFrameTxt:TextArea;
@@ -50,16 +53,12 @@ class StageEditorObjectGraphicToolbox extends StageEditorDefaultToolbox
if (imageInfo == null) return;
objImage.resource = imageInfo.data;
linkedObj.frame = imageInfo.data;
// This checks if the same image had already been loaded, so that we don't add it twice.
// Kind of hacky but it is what it is.
var name:String = haxe.io.Path.withoutExtension(selectedFile.name);
var bitToLoad:String = state.addBitmap(linkedObj.updateFramePixels(), name);
linkedObj.loadGraphic(state.bitmaps[bitToLoad]);
var file:StageEditorAssetFile = state.createFile(selectedFile.name, selectedFile.bytes);
linkedObj.loadGraphic(BitmapData.fromBytes(file.data));
linkedObj.updateHitbox();
state.removeUnusedBitmaps();
linkedObj.usedFiles.push(file);
refresh();
objImageWidth.pos = objImageWidth.max;
@@ -77,11 +76,11 @@ class StageEditorObjectGraphicToolbox extends StageEditorDefaultToolbox
state.createURLDialog(function(bytes:lime.utils.Bytes)
{
var bitToLoad:String = state.addBitmap(BitmapData.fromBytes(bytes), linkedObj.name);
linkedObj.loadGraphic(state.bitmaps[bitToLoad]);
var file:StageEditorAssetFile = state.createFile('${linkedObj.name}.png', bytes);
linkedObj.loadGraphic(BitmapData.fromBytes(file.data));
linkedObj.updateHitbox();
state.removeUnusedBitmaps();
linkedObj.usedFiles.push(file);
// Update the image preview.
refresh();
@@ -90,6 +89,34 @@ class StageEditorObjectGraphicToolbox extends StageEditorDefaultToolbox
});
}
// Callback for loading a texture atlas.
objLoadTextureAtlas.onClick = function(_)
{
if (linkedObj == null) return;
FileUtil.browseForDirectory("Open Exported Texture Atlas", function(path:String)
{
var files:Array<String> = FileUtil.readDir(path);
if (!files.containsAllExact(["Animation.json", "spritemap1.json", "spritemap1.png"]))
{
state.notifyChange('Texture Atlas Loading Error', 'The folder $path doesn\'t contain required assets for a Texture Atlas.', true);
return;
}
if (files.contains('metadata.json'))
{
state.notifyChange('Texture Atlas Loading Error', 'The Texture Atlas from the folder $path doesn\'t have inlined assets.', true);
return;
}
var dialog:TextureAtlasSettingsDialog = new TextureAtlasSettingsDialog(state, path);
dialog.showDialog();
/**/
});
}
// Callback for resetting the image.
objReset.onClick = function(_)
{
@@ -98,9 +125,6 @@ class StageEditorObjectGraphicToolbox extends StageEditorDefaultToolbox
linkedObj.loadGraphic(AssetDataHandler.getDefaultGraphic());
linkedObj.updateHitbox();
// remove unused bitmaps
state.removeUnusedBitmaps();
// Update the image preview.
refresh();
stageEditorState.updateDialog(OBJECT_ANIMS);
@@ -111,7 +135,11 @@ class StageEditorObjectGraphicToolbox extends StageEditorDefaultToolbox
{
if (linkedObj == null) return;
var file:Null<StageEditorAssetFile> = linkedObj.usedFiles.find(f -> f.name.endsWith(".png"));
linkedObj.loadGraphic(linkedObj.graphic);
if (file != null) linkedObj.usedFiles.push(file);
refresh();
stageEditorState.updateDialog(OBJECT_ANIMS);
}
@@ -124,7 +152,6 @@ class StageEditorObjectGraphicToolbox extends StageEditorDefaultToolbox
if (selectedFile != null && selectedFile.bytes != null)
{
objFrameTxt.text = selectedFile.bytes.toString();
state.notifyChange("Frame Text Loaded", "The Text File " + selectedFile.name + " has been loaded.");
}
});
@@ -141,6 +168,7 @@ class StageEditorObjectGraphicToolbox extends StageEditorDefaultToolbox
{
if (linkedObj == null) return;
var file:StageEditorAssetFile = state.createFile('${linkedObj.name}.png', linkedObj.pixels.image.encode());
linkedObj.loadGraphic(linkedObj.graphic, true, Std.int(objImageWidth.pos), Std.int(objImageHeight.pos));
linkedObj.updateHitbox();
@@ -153,6 +181,12 @@ class StageEditorObjectGraphicToolbox extends StageEditorDefaultToolbox
linkedObj.frames.frames[i].name = 'Frame$i';
}
linkedObj.usedFiles.push(file);
// Add a generated XML file to the files, so that this sprite can be used in-game.
var name:String = Path.withoutExtension(linkedObj.usedFiles[0].name);
linkedObj.usedFiles.push(state.createFile('$name.xml', Bytes.ofString(AssetDataHandler.generateXML(linkedObj, name))));
// Refresh the display.
refresh();
@@ -185,13 +219,15 @@ class StageEditorObjectGraphicToolbox extends StageEditorDefaultToolbox
}
/**
* Set the linked object's frames based on its graphic and loaded text.
* Set the linked object's frames based on its graphic and loaded text. Only for sparrow and packer atlases.
* @param usePacker
*/
function setObjFrames(usePacker:Bool)
{
if (linkedObj == null || objFrameTxt.text == null || objFrameTxt.text.length == 0) return;
var file:StageEditorAssetFile = stageEditorState.createFile('${linkedObj.name}.png', linkedObj.pixels.image.encode());
try
{
if (usePacker)
@@ -209,9 +245,13 @@ class StageEditorObjectGraphicToolbox extends StageEditorDefaultToolbox
return;
}
linkedObj.animDatas.clear();
linkedObj.animation.destroyAnimations();
linkedObj.updateHitbox();
linkedObj.usedFiles.push(file);
var name:String = Path.withoutExtension(linkedObj.usedFiles[0].name);
linkedObj.usedFiles.push(stageEditorState.createFile('$name.${usePacker ? "txt" : "xml"}', Bytes.ofString(objFrameTxt.text)));
refresh();
stageEditorState.notifyChange("Frame Setup Done", "Finished the Frame Setup for the Object " + linkedObj.name + ".");