diff --git a/source/funkin/backend/chart/EventsData.hx b/source/funkin/backend/chart/EventsData.hx index d9c7267c0a..7d6981555c 100644 --- a/source/funkin/backend/chart/EventsData.hx +++ b/source/funkin/backend/chart/EventsData.hx @@ -10,7 +10,7 @@ import openfl.Assets; using StringTools; class EventsData { - public static var defaultEventsList:Array = ["HScript Call", "Camera Movement", "Camera Position", "Add Camera Zoom", "Camera Bop", "Camera Zoom", "Camera Modulo Change", "Camera Flash", "BPM Change", "Continuous BPM Change", "Time Signature Change", "Scroll Speed Change", "Alt Animation Toggle", "Play Animation"]; + public static var defaultEventsList:Array = ["HScript Call", "Camera Movement", "Camera Position", "Add Camera Zoom", "Camera Bop", "Camera Zoom", "Camera Modulo Change", "Camera Flash", "BPM Change", "Continuous BPM Change", "Time Signature Change", "Scroll Speed Change", "Alt Animation Toggle", "Play Animation", "Change Stage", "Change Character"]; public static var defaultEventsParams:Map> = [ "HScript Call" => [ {name: "Function Name", type: TString, defValue: "myFunc"}, @@ -121,6 +121,23 @@ class EventsData { defValue: "NONE" } ], + "Change Stage" => [ + {name: "Stage Name", type: TString, defValue: ""}, + {name: "Cached?", type: TBool, defValue: true}, + {name: "Destroy Previous Stage", type: TBool, defValue: false}, + {name: "Force Reload?", type: TBool, defValue: false} + ], + "Change Character" => [ + {name: "Character from StrumLine", type: TStrumLine, defValue: 0}, + {name: "Character StrumLine Index", type: TInt(0, 9999999, 1), defValue: 0}, + {name: "Character Name", type: TString, defValue: "bf"}, + {name: "Override isPlayer?", type: TBool, defValue: false}, + {name: "isPlayer?", type: TBool, defValue: false}, + + {name: "Cache?", type: TBool, defValue: true}, + {name: "Destroy Previous Character", type: TBool, defValue: false}, + {name: "Auto Stage Position", type: TBool, defValue: true}, + ] ]; public static var eventsList:Array = defaultEventsList.copy(); diff --git a/source/funkin/backend/scripting/events/stage/StageNodeEvent.hx b/source/funkin/backend/scripting/events/stage/StageNodeEvent.hx index 505e92b897..1935a28e6c 100644 --- a/source/funkin/backend/scripting/events/stage/StageNodeEvent.hx +++ b/source/funkin/backend/scripting/events/stage/StageNodeEvent.hx @@ -7,7 +7,7 @@ final class StageNodeEvent extends CancellableEvent { /** * The stage instance */ - public var stage:Stage; + public var stage:Dynamic; /** * The node which is currently being parsed diff --git a/source/funkin/backend/scripting/events/stage/StageXMLEvent.hx b/source/funkin/backend/scripting/events/stage/StageXMLEvent.hx index 9bc6f10b60..3336030c58 100644 --- a/source/funkin/backend/scripting/events/stage/StageXMLEvent.hx +++ b/source/funkin/backend/scripting/events/stage/StageXMLEvent.hx @@ -7,7 +7,7 @@ final class StageXMLEvent extends CancellableEvent { /** * The stage instance */ - public var stage:Stage; + public var stage:Dynamic; /** * The xml diff --git a/source/funkin/backend/utils/CoolUtil.hx b/source/funkin/backend/utils/CoolUtil.hx index 382c476046..9aee24ec94 100644 --- a/source/funkin/backend/utils/CoolUtil.hx +++ b/source/funkin/backend/utils/CoolUtil.hx @@ -704,7 +704,7 @@ final class CoolUtil * @param xml XML to get the attribute from * @param name Name of the attribute */ - public static inline function getAtt(xml:Access, name:String) { + public static inline function getAtt(xml:Access, name:String):Null { /*if (!xml.has.resolve(name)) return null; return xml.att.resolve(name);*/ // Reason for change: diff --git a/source/funkin/game/Character.hx b/source/funkin/game/Character.hx index 5a2cc48995..1045edf2fa 100644 --- a/source/funkin/game/Character.hx +++ b/source/funkin/game/Character.hx @@ -14,6 +14,7 @@ import funkin.backend.scripting.Script; import funkin.backend.scripting.ScriptPack; import funkin.backend.scripting.events.CancellableEvent; import funkin.backend.scripting.events.character.*; +import funkin.backend.scripting.events.sprite.PlayAnimContext; import funkin.backend.scripting.events.sprite.*; import funkin.backend.scripting.events.PointEvent; import funkin.backend.scripting.events.DrawEvent; diff --git a/source/funkin/game/NewStage.hx b/source/funkin/game/NewStage.hx new file mode 100644 index 0000000000..d7ab1587d6 --- /dev/null +++ b/source/funkin/game/NewStage.hx @@ -0,0 +1,956 @@ +package funkin.game; + +import animate.internal.RenderTexture; + +import funkin.game.Stage.StageCharPos; +import funkin.game.Stage.StageCharPosInfo; +import funkin.backend.utils.XMLUtil; +import funkin.backend.scripting.Script; +import funkin.backend.scripting.events.stage.StageXMLEvent; +import funkin.backend.system.interfaces.IBeatReceiver; + +import flixel.group.FlxGroup; +import flixel.math.FlxPoint; +import flixel.math.FlxRect; +import flixel.math.FlxMatrix; +import flixel.util.FlxSignal.FlxTypedSignal; +import flixel.util.FlxStringUtil; +import flixel.util.FlxStringUtil.LabelValuePair; +import flixel.util.FlxDestroyUtil; + +import flixel.system.FlxAssets.FlxShader; + +import haxe.xml.Access; +import hscript.IHScriptCustomBehaviour; + +import openfl.display.BlendMode; + +using StringTools; + +/** + * A class that handles loading a stage and putting the sprites into the state. +**/ +class StageLayer extends FlxTypedGroup implements IBeatReceiver implements IHScriptCustomBehaviour { + private static final __instanceFields = Type.getInstanceFields(StageLayer); + + public var name:String; + public var x(get, never):Float; + public var y(get, never):Float; + public var width(get, never):Float; + public var height(get, never):Float; + public var bounds(get, never):FlxRect; + + /** + * WARNING: The `origin` of the layer will default to its center. If you change this, + * the visuals will likely be pretty out-of-sync if you do any rotation. + */ + public var origin(default, null):FlxPoint = FlxPoint.get(); // This will be used for rendering btw + + public var onAddSprite:FlxTypedSignal Void> = new FlxTypedSignal(); + public var onAddLayer:FlxTypedSignal Void> = new FlxTypedSignal(); + + /** + * Whether to internally use a render texture when drawing the stage layer. + * This flattens all of the sprites and subsequent layers into a single graphic, making effects such as alpha or shaders apply to + * the entire sprite instead of individual members of the group. + */ + public var useRenderTexture:Bool = false; + private var _renderTexture:RenderTexture; + private var _renderTextureDirty:Bool = true; + + // TODO: use FlxAnimate's implementation on useRenderTexture so we can apply the shader onto all sprites as 1 sprite !!! + public var shader(default, set):FlxShader; + // this setter is a temporary plug until we get `useRenderTexture` working + private function set_shader(s:FlxShader):FlxShader { + if (useRenderTexture) { + this.shader = s; + return s; + } + for (member in this.members) { + if(member is FlxSprite) + cast(member, FlxSprite).shader = s; + else if(member is StageLayer) + cast(member, StageLayer).shader = s; + } + this.shader = s; + return s; + } + + public var alpha(default, set):Float = 1.0; + private function set_alpha(a:Float):Float { + if (useRenderTexture) { + this.alpha = a; + return a; + } + for (member in this.members) { + if (member is FlxSprite) + cast(member, FlxSprite).alpha = a; + else if (member is StageLayer) + cast(member, StageLayer).alpha = a; + } + this.alpha = a; + return a; + } + + public var scrollFactor(default, set):FlxPoint = FlxPoint.get(1, 1); + private function set_scrollFactor(s:FlxPoint):FlxPoint { + if (useRenderTexture) { + this.scrollFactor = s; + return s; + } + for (member in this.members) { + if (member is FlxSprite) { + var spr:FlxSprite = cast member; + spr.scrollFactor.x += s.x; + spr.scrollFactor.y += s.y; + } + else if (member is StageLayer) { + var layer:StageLayer = cast member; + layer.scrollFactor.x += s.x; + layer.scrollFactor.y += s.y; + } + } + this.scrollFactor = s; + return s; + } + + public var blend(default, set):BlendMode = null; + private function set_blend(b:BlendMode):BlendMode { + if(useRenderTexture) + return this.blend = b; + + for(member in this.members) { + if(member is FlxSprite) + cast(member, FlxSprite).blend = b; + else if(members is StageLayer) + cast(member, StageLayer).blend = b; + } + return this.blend = b; + } + + public inline function getSprite(name:String):Null { + return stageSprites.exists(name) ? stageSprites[name] : null; + } + + public inline function getLayer(name:String):Null { + return stageLayers.exists(name) ? cast stageLayers[name] : null; + } + + public function new(name:String = "stage_layer", useRenderTexture:Bool = false) { + super(); + this.name = name; + this.useRenderTexture = useRenderTexture; + this.memberAdded.add((obj) -> { + if(obj is FlxObject) { + if(obj is StageCharPos) return; + if(obj is StageLayer) + onAddLayer.dispatch(cast obj); + else + onAddSprite.dispatch(cast obj); + updateBounds(); + } + }); + this.memberRemoved.add((obj) -> { + if(obj == null || obj is FlxObject) + updateBounds(); + }); + } + + function checkRenderTexture():Bool { + return useRenderTexture && (alpha != 1 || shader != null || (blend != null && blend != NORMAL)); + } + + override function draw() { + if(!checkRenderTexture()) { + super.draw(); + return; + } + + for(cam in this.cameras) { + if (!cam.visible || !cam.exists) + continue; + + drawLayer(cam); + + #if FLX_DEBUG + flixel.FlxBasic.visibleCount++; + #end + } + } + + @:noCompletion + private var layerMatrix:FlxMatrix = new FlxMatrix(); + @:noCompletion + private var _point:FlxPoint = FlxPoint.get(); + // TODO: + private function prepareLayerMatrix(matrix:FlxMatrix, camera:FlxCamera) { + matrix.translate(-origin.x, -origin.y); + + //getScreenPosition(_point, camera).subtractPoint(offset).add(origin.x, origin.y); + CoolUtil.pointToScreenPosition(FlxPoint.weak(x, y), camera, _point).add(origin.x, origin.y); + matrix.translate(_point.x, _point.y); + + if(camera.pixelPerfectRender) { + matrix.tx = Math.floor(matrix.tx); + matrix.ty = Math.floor(matrix.ty); + } + } + + private function drawLayer(cam:FlxCamera) { + layerMatrix.identity(); + prepareLayerMatrix(layerMatrix, cam); + + if(_renderTextureDirty) { + renderLayer(); + //_renderTextureDirty = false; + cam.drawPixels(_renderTexture.graphic.imageFrame.frame, null, layerMatrix, null, null, flixel.FlxSprite.defaultAntialiasing, shader != null ? shader : null); + } + } + + private function renderLayer() { + if (_renderTexture == null) + _renderTexture = new RenderTexture(Math.ceil(_bounds.width), Math.ceil(_bounds.height)); + _renderTexture.init(Math.ceil(_bounds.width), Math.ceil(_bounds.height)); + _renderTexture.drawToCamera((camera, matrix) -> { + matrix.translate(-_bounds.x, -_bounds.y); + drawMembers(camera, matrix); + }); + _renderTexture.render(); + } + + @:access(animate.internal.RenderTexture) + @:access(flixel.FlxSprite) + private function drawMembers(cam:FlxCamera, matrix:FlxMatrix) { + for(m in members) { + if(m is StageLayer) { + var sl:StageLayer = cast m; + if(sl.useRenderTexture) { + sl.renderLayer(); // cache the rendered layer + cam.drawPixels(sl._renderTexture.graphic.imageFrame.frame, null, matrix, null, null, flixel.FlxSprite.defaultAntialiasing, shader != null ? shader : null); + } + else + sl.draw(); + } + else if(m is StageCharPos) + continue; + else if(m is FlxSprite){ // TODO: maybe take into account the `onDraw` callback + var spr:FlxSprite = cast m; + spr.drawComplex(cam); + } + #if FLX_DEBUG + else if(m is FlxObject) { + cast(m, FlxObject).drawDebugOnCamera(cam); + } + #end + } + } + + private var stageSprites:Map = []; + private var stageLayers:Map = []; + + //region Stage Layer Management + override public function add(basic:FlxBasic):FlxBasic { + if (!(basic is StageLayer)) return super.add(basic); + + var layer:StageLayer = cast basic; + if (stageLayers.exists(layer.name)) return stageLayers.get(layer.name); + + stageLayers.set(layer.name, layer); + return super.add(layer); + } + + override public function insert(position:Int, basic:FlxBasic):FlxBasic { + if (!(basic is StageLayer)) return super.insert(position, basic); + var layer:StageLayer = cast basic; + if (stageLayers.exists(layer.name)) return stageLayers.get(layer.name); + + stageLayers.set(layer.name, layer); + return super.insert(position, layer); + } + + override public function remove(basic:FlxBasic, splice:Bool = false):FlxBasic { + if (!(basic is StageLayer)) return super.remove(basic, splice); + + var layer:StageLayer = cast basic; + stageLayers.remove(layer.name); + return super.remove(layer, splice); + } + //endregion + + //region Stage Sprite Management + public function addSprite(name:String, spr:FlxObject):FlxObject { + if (stageSprites.exists(name)) return spr; + + this.add(spr); + stageSprites.set(name, spr); + + return spr; + } + + public function insertSprite(index:Int, name:String, spr:FlxObject):FlxObject { + if (stageSprites.exists(name)) return spr; + + this.insert(index, spr); + stageSprites.set(name, spr); + + return spr; + } + + public function removeSprite(name:String, splice:Bool = false):Bool { + if (!stageSprites.exists(name)) return false; + + var spr:FlxBasic = stageSprites.get(name); + + this.remove(spr, splice); + stageSprites.remove(name); + + return true; + } + //endregion + + //region IBeatReceiver implementation + public function beatHit(curBeat:Int) { + for(m in members) if(m != null && m is IBeatReceiver) cast(m, IBeatReceiver).beatHit(curBeat); + } + public function stepHit(curStep:Int) { + for(m in members) if(m != null && m is IBeatReceiver) cast(m, IBeatReceiver).stepHit(curStep); + } + public function measureHit(curMeasure:Int) { + for(m in members) if(m != null && m is IBeatReceiver) cast(m, IBeatReceiver).measureHit(curMeasure); + } + //endregion + + //region IHScriptCustomBehaviour implementation + public function hget(name:String):Dynamic { + if (__instanceFields.contains(name) || __instanceFields.contains('get_$name')) + return Reflect.getProperty(this, name); + if (stageSprites.exists(name)) return stageSprites[name]; + if (stageLayers.exists(name)) return stageLayers[name]; + return null; + } + + public function hset(name:String, val:Dynamic):Dynamic { + if (__instanceFields.contains(name) || __instanceFields.contains('set_$name')) { + Reflect.setProperty(this, name, val); + return val; + } + if (stageSprites.exists(name)) return stageSprites[name] = val; + if (stageLayers.exists(name)) return stageLayers[name] = val; + return null; + } + //endregion + + // then whatever below + override function destroy() { + super.destroy(); + _bounds = FlxDestroyUtil.put(_bounds); + origin = FlxDestroyUtil.put(origin); + } + + private var _bounds:FlxRect = FlxRect.get(); + private function get_bounds():FlxRect { return _bounds; } + + private inline function __shouldUpdateBounds(m:FlxBasic):Bool { + return (m != null && !(m is StageCharPos) && m is FlxObject); + } + + /** + * Updates the bounds of the layer. + * Without `hard_check` enabled, it will check values of `x`, `y`, `width` and `height` of the members directly. + * This won't propagate `updateBounds` or `findMinX`, `findMinY`, `findMaxX` and `findMaxY` to the members, since it access the values directly. + * This is faster but under some circumstances, it might not be 100% accurate. + * @param hard_check If true, it will use `findMinX`, `findMinY`, `findMaxX` and `findMaxY` to find the bounds, which is slower but is guaranteed to be accurate when running + */ + private function updateBounds(hard_check:Bool = false) { + if (this.length == 0) return; + + if (hard_check) { + var x = findMinX(); + var y = findMinY(); + var width = findMaxX() - x; + var height = findMaxY() - y; + + _bounds.set(x, y, width, height); + } else { + var minX:Float = Math.POSITIVE_INFINITY; + var minY:Float = Math.POSITIVE_INFINITY; + + var maxX:Float = Math.NEGATIVE_INFINITY; + var maxY:Float = Math.NEGATIVE_INFINITY; + for (m in this.members) { + if (!__shouldUpdateBounds(m)) continue; + + if (m is StageLayer) { + var layer:StageLayer = cast m; + if (layer.x < minX) minX = layer.x; + if (layer.y < minY) minY = layer.y; + if (layer.x + layer.width > maxX) maxX = layer.x + layer.width; + if (layer.y + layer.height > maxY) maxY = layer.y + layer.height; + } else { + var obj:FlxObject = cast m; + if (obj.x < minX) minX = obj.x; + if (obj.y < minY) minY = obj.y; + if (obj.x + obj.width > maxX) maxX = obj.x + obj.width; + if (obj.y + obj.height > maxY) maxY = obj.y + obj.height; + } + } + + _bounds.set(minX, minY, maxX - minX, maxY - minY); + } + + origin = _bounds.getMidpoint(origin); + } + + // From FlxSpriteGroup + + private function findMinX():Float { + if(this.length == 0) return 0; + var value = Math.POSITIVE_INFINITY; + + for(m in this.members) { + if (!__shouldUpdateBounds(m)) continue; + + var minX:Float; + if(m is StageLayer) minX = cast(m, StageLayer).findMinX(); + else minX = cast(m, FlxObject).x; + + if (minX < value) value = minX; + } + + return value; + } + + private function findMaxX():Float { + if(this.length == 0) return 0; + var value = Math.NEGATIVE_INFINITY; + + for(m in this.members) { + if (!__shouldUpdateBounds(m)) continue; + + var maxX:Float; + if(m is StageLayer) maxX = cast(m, StageLayer).findMaxX(); + else { + var obj:FlxObject = cast m; + maxX = obj.x + obj.width; + } + + if (maxX > value) value = maxX; + } + + return value; + } + + private function findMinY():Float { + if(this.length == 0) return 0; + var value = Math.POSITIVE_INFINITY; + + for(m in this.members) { + if (!__shouldUpdateBounds(m)) continue; + + var minY:Float; + if(m is StageLayer) minY = cast(m, StageLayer).findMinY(); + else minY = cast(m, FlxObject).y; + + if (minY < value) value = minY; + } + + return value; + } + + private function findMaxY():Float { + if(this.length == 0) return 0; + var value = Math.NEGATIVE_INFINITY; + + for(m in this.members) { + if (!__shouldUpdateBounds(m)) continue; + + var maxY:Float; + if(m is StageLayer) maxY = cast(m, StageLayer).findMaxY(); + else { + var obj:FlxObject = cast m; + maxY = obj.y + obj.height; + } + + if (maxY > value) value = maxY; + } + + return value; + } + + private function get_x():Float + return _bounds.x; + private function get_y():Float + return _bounds.y; + private function get_width():Float + return _bounds.width; + private function get_height():Float + return _bounds.height; + + override public function toString():String { + return '(Stage Layer) $name: ${FlxStringUtil.getDebugString([ + LabelValuePair.weak("x", x), + LabelValuePair.weak("y", y), + LabelValuePair.weak("width", width), + LabelValuePair.weak("height", height), + ])}'; + } +} + +class NewStage extends StageLayer { + private static final __instanceFields = Type.getInstanceFields(NewStage); + + public static var CACHE:Map = []; + public inline static function clear_cache() { + for (name=>stage in CACHE) stage?.destroy(); + CACHE.clear(); + } + + public static function cache(name:String, reload:Bool = false):NewStage { + if (CACHE.exists(name) && !reload) return CACHE[name]; + else if (CACHE.exists(name) && reload) { + var stage:NewStage = CACHE[name]; + stage?.destroy(); + FlxG.state?.remove(stage, true); + } + + var stage:NewStage = new NewStage(name, true); + stage.active = stage.exists = stage.visible = false; + + CACHE[name] = stage; + + return stage; + } + + private static final DEFAULT_ATTRIBUTES:Array = ["name", "startCamPosX", "startCamPosY", "zoom", "folder", "useRenderTexture"]; + + private static inline function getDefaultPos(name:String):StageCharPosInfo { + return switch(name) { + case "boyfriend" | "bf" | "player": + {x: 770, y: 100, scroll: 1, flip: true}; + case "girlfriend" | "gf": + {x: 400, y: 130, scroll: 0.95, flip: false}; + case "dad" | "opponent": + {x: 100, y: 100, scroll: 1, flip: false}; + default: + {x: 0, y: 0, scroll: 1, flip: false}; + } + } + + public final fileName:String; + public final xmlFilePath:String; + public final scriptFilePath:String; + + public var xmlFile:Access; + + public var script:Script; + public var allowScripts:Bool = true; + public var xmlImportedScripts:Array = []; + + public var defaultZoom:Float = 1.05; + public var spritesParentFolder = ""; + public var extra:Map = []; + public var startCam:FlxPoint = FlxPoint.get(); + + // Callbacks + public var onStageScriptLoad:Script -> Void; + public var onPostStageCreation:StageXMLEvent->Void; + + public var onPrepareInfo:Access -> XMLImportedScriptInfo; + public var onRemoveInfo:Script -> Void; + + public var onXMLLoaded:(StageXMLEvent)->Array = null; + public var onNodeInitalize:(Access)->Dynamic = null; + public var onNodeLoaded:(Access, Dynamic)->Dynamic = null; + public var onNodeFinished:(Access, Dynamic)->Void = null; + public var onXMLPostLoaded:(Access, Array)->Array = null; + + public var onStartCamSet:FlxPoint -> Float -> Void; + public var onRatingSet:Float->Float->FlxBasic; + + public var onStageDestroy:NewStage -> Void; + public var onSilentDestroy:Script -> Void; + + private var characterPosLookup:Map = []; + + public var hasLoaded:Bool = false; + + /** + * Sets the sprites in the script, so you can access them by the name. + **/ + public function setStagesSprites(script:Script) + for (key=>ref in stageSprites) script.set(key, ref); + + public function new(stage:String, load:Bool = false) { + super(); + + fileName = stage; + xmlFilePath = Paths.xml('stages/$fileName'); + scriptFilePath = Paths.script('data/stages/$fileName'); + if (Assets.exists(xmlFilePath)) { + try xmlFile = new Access(Xml.parse(Assets.getText(xmlFilePath)).firstElement()) + catch (e) Logs.trace('Couldn\'t load stage "$xmlFilePath": ${e.message}', ERROR); + } + + if (load) loadStage(); + } + + private var stageEvent:StageXMLEvent; + + public function loadStage(loadAll:Bool = false):Void { + if (hasLoaded) return; + if (allowScripts) { + script = Script.create(scriptFilePath); + if (onStageScriptLoad != null) onStageScriptLoad(script); + script.setParent(this); + script.load(); + script.call("create"); + script.call("onStageLoad"); + + onAddSprite.add((obj:Dynamic) -> script.call("onAddSprite", [obj])); + onAddLayer.add((layer:StageLayer) -> script.call("onAddLayer", [layer])); + } + + if (xmlFile == null) { + postLoadStage(null); + return; + } + + loadStartCam(); + + this.name = xmlFile.getAtt("name").getDefault(fileName); + this.useRenderTexture = xmlFile.has.useRenderTexture ? xmlFile.att.useRenderTexture == "true" : false; + + if (onStartCamSet != null) + onStartCamSet(startCam, defaultZoom); + + if (xmlFile.has.folder) { + spritesParentFolder = xmlFile.att.folder; + if (!spritesParentFolder.endsWith("/")) + spritesParentFolder += "/"; + } + + // Load custom attributes + loadCustomAttributes(); + + var elems:Array = []; + + // some way to tag that the sprites are from the group + checkMemoryMode(xmlFile, loadAll, elems); + + if (onXMLLoaded != null) { + stageEvent = EventManager.get(StageXMLEvent).recycle(this, xmlFile, elems); + elems = onXMLLoaded(stageEvent); + } + + loadLayer(this, elems); + + postLoadStage(elems); + script?.call("postCreate"); + script?.call("onPostStageLoad"); + hasLoaded = true; + } + + private inline function loadStartCam() { + startCam.x = Std.parseFloat(xmlFile.getAtt("startCamPosX")).getDefaultFloat(0); + startCam.y = Std.parseFloat(xmlFile.getAtt("startCamPosY")).getDefaultFloat(0); + defaultZoom = Std.parseFloat(xmlFile.getAtt("zoom")).getDefaultFloat(1.05); + } + + private inline function loadCustomAttributes() { + for (att in xmlFile.x.attributes()) + if (!DEFAULT_ATTRIBUTES.contains(att)) + extra.set(att, xmlFile.x.get(att)); + } + + private function loadLayer(layer:StageLayer, elems:Array) { + for(node in elems) { + // If `onNodeInitalize` returns a valid value, then why waste time on checking other values, + // since we should only care about what the user sets it too. Optimizations be like: + var sprite:Dynamic = (onNodeInitalize != null) ? onNodeInitalize(node) : null; + if (sprite == null) sprite = switch(node.name) { + case "layer": + if (!node.has.name) continue; + + var layerName:String = node.att.name; + var renderLayer:Bool = node.has.useRenderTexture ? node.att.useRenderTexture == "true" : false; + var layer:StageLayer = new StageLayer(layerName, renderLayer); + // recursive so it will allow nested layers + script?.call("onLoadLayer", [layer]); + loadLayer(layer, [for(n in node.elements) n]); + script?.call("onPostLoadLayer", [layer]); + add(layer); + case "sprite" | "spr" | "sparrow": + if (!node.has.name) continue; + + var spr = XMLUtil.createSpriteFromXML(node, spritesParentFolder, LOOP); + addSprite(spr.name, spr); + case "box" | "solid": + if (!node.has.name || !node.has.width || !node.has.height) + continue; + + var isSolid = node.name == "solid"; + + var spr = new FunkinSprite(); + var w:Int = Std.parseInt(node.att.width); + var h:Int = Std.parseInt(node.att.height); + var c:flixel.util.FlxColor = (node.has.color) ? CoolUtil.getColorFromDynamic(node.att.color) : -1; + if (isSolid) { + spr.makeSolid(w, h, c); + node.x.remove("updateHitbox"); + } else + spr.makeGraphic(w, h, c); + + node.x.remove("width"); node.x.remove("height"); node.x.remove("color"); + XMLUtil.loadSpriteFromXML(spr, node, "", NONE, false); + + addSprite(spr.name, spr); + case "boyfriend" | "bf" | "player": + setCharPos("boyfriend", node, getDefaultPos("boyfriend")); + case "girlfriend" | "gf": + setCharPos("girlfriend", node, getDefaultPos("girlfriend")); + case "dad" | "opponent": + setCharPos("dad", node, getDefaultPos("dad")); + case "character" | "char": + if (!node.has.name) continue; + setCharPos(node.att.name, node); + case "ratings" | "combo": + if (onRatingSet == null) continue; + onRatingSet(Std.parseFloat(node.getAtt("x")), Std.parseFloat(node.getAtt("y"))); + default: + // moved it to be like this, so we can just update the inline function - LJ + if (__isExtensionNode(node)) { + if (XMLImportedScriptInfo.shouldLoadBefore(node)) continue; + if (onPrepareInfo != null && onPrepareInfo(node) == null) continue; + null; + } + null; + } + + if (onNodeLoaded != null) { + var _prevSprite = sprite; + sprite = onNodeLoaded(node, sprite); + // cleanup since there will be a random sprite floating around in memory + if (_prevSprite != sprite && _prevSprite != null) _prevSprite.destroy(); + } + + if (sprite != null) { + for (e in node.nodes.property) + XMLUtil.applyXMLProperty(sprite, e); + } + + if (onNodeFinished != null) { + onNodeFinished(node, sprite); + } + } + } + + private function postLoadStage(?elems:Array) { + for(defaultChar in ["girlfriend", "dad", "boyfriend"]) { + if (!characterPosLookup.exists(defaultChar)) + setCharPos(defaultChar, null, getDefaultPos(defaultChar)); + } + + if (allowScripts) { + setStagesSprites(this.script); + + // i know this for gets run twice under, but its better like this in case a script modifies the short lived ones, i dont wanna save them in an array; more dynamic like this - Nex + for (info in xmlImportedScripts) if (info.importStageSprites) { + var scriptInfo = info.getScript(); + if (scriptInfo != null) setStagesSprites(scriptInfo); + } + + // idk lemme check anyways just in case scripts did smth - Nex + if(onPostStageCreation != null && stageEvent != null) + onPostStageCreation(stageEvent); + + // shortlived scripts destroy when the stage finishes setting up - Nex + for (info in xmlImportedScripts) if (info.shortLived) { + var scriptInfo = info.getScript(); + if (scriptInfo == null) continue; + + if (onRemoveInfo != null) onRemoveInfo(scriptInfo); + scriptInfo.destroy(); + } + } + + if (xmlFile != null && onXMLPostLoaded != null) { + elems = onXMLPostLoaded(xmlFile, elems); + } + } + + private function setCharPos(name:String, ?node:Access, ?defaultCharPos:StageCharPosInfo) { + var charPos = new StageCharPos(); + charPos.visible = charPos.active = false; + charPos.name = name; + + if (defaultCharPos != null) { + charPos.setPosition(defaultCharPos.x, defaultCharPos.y); + charPos.scrollFactor.set(defaultCharPos.scroll, defaultCharPos.scroll); + charPos.flipX = defaultCharPos.flip; + } + + if (node != null) { + charPos.x = Std.parseFloat(node.getAtt("x")).getDefault(charPos.x); + charPos.y = Std.parseFloat(node.getAtt("y")).getDefault(charPos.y); + + charPos.charSpacingX = Std.parseFloat(node.getAtt("spacingx")).getDefault(charPos.charSpacingX); + charPos.charSpacingY = Std.parseFloat(node.getAtt("spacingy")).getDefault(charPos.charSpacingY); + + charPos.camxoffset = Std.parseFloat(node.getAtt("camxoffset")).getDefault(charPos.camxoffset); + charPos.camyoffset = Std.parseFloat(node.getAtt("camyoffset")).getDefault(charPos.camyoffset); + + charPos.skewX = Std.parseFloat(node.getAtt("skewx")).getDefault(charPos.skewX); + charPos.skewY = Std.parseFloat(node.getAtt("skewy")).getDefault(charPos.skewY); + + charPos.alpha = Std.parseFloat(node.getAtt("alpha")).getDefault(charPos.alpha); + charPos.angle = Std.parseFloat(node.getAtt("angle")).getDefault(charPos.angle); + charPos.flipX = (node.has.flip || node.has.flipX) ? (node.getAtt("flip") == "true" || node.getAtt("flipX") == "true") : charPos.flipX; + charPos.zoomFactor = Std.parseFloat(node.getAtt("zoomfactor")).getDefault(charPos.zoomFactor); + + // Scaling + if (node.has.scale) { + var scale:Float = Std.parseFloat(node.att.scale).getDefaultFloat(1); + charPos.scale.set(scale, scale); + } + if (node.has.scalex) charPos.scale.x = Std.parseFloat(node.att.scalex).getDefaultFloat(1); + if (node.has.scaley) charPos.scale.y = Std.parseFloat(node.att.scaley).getDefaultFloat(1); + + // Scroll Factor + if (node.has.scroll) { + var scroll:Float = Std.parseFloat(node.att.scroll).getDefaultFloat(1); + charPos.scrollFactor.set(scroll, scroll); + } + if (node.has.scrollx) charPos.scrollFactor.x = Std.parseFloat(node.att.scrollx).getDefaultFloat(1); + if (node.has.scrolly) charPos.scrollFactor.y = Std.parseFloat(node.att.scrolly).getDefaultFloat(1); + } + return add(characterPosLookup[name] = charPos); + } + + /** + * Checks if a character is flipped or not. + * @param posName The name of the character position + * @param def The default value + **/ + public inline function isCharFlipped(posName:String, isPlayer:Bool = false) + return characterPosLookup.exists(posName) ? characterPosLookup[posName].flipX : isPlayer; + + /** + * Applies the character position to the character. + * @param char The character to apply the position to. + * @param posName The name of the character position. + * @param id ?????? no fucking clue why does it have an ID it's never used!!!!!!!!!!!!!!!! + **/ + public function applyCharPos(char:Character, posName:String, id:Float = 0) { + var charName:String = char.curCharacter; + var charPos:Null = characterPosLookup.exists(charName) ? characterPosLookup.get(charName) : characterPosLookup.get(posName); + if(charPos != null) { + charPos.prepareCharacter(char, id); + this.insert(this.members.indexOf(charPos), char); + } + else + this.add(char); + } + + /** + * Same of destroy, but doesn't call the various script events. + * @param destroySprites Whether the stage sprites should be destroyed + * @param destroyScript Whether the stage script should be destroyed + **/ + public function destroySilently(destroySprites:Bool = true, destroyScript:Bool = true) { + if (destroyScript && script != null) { + if (onSilentDestroy != null) onSilentDestroy(this.script); + script.destroy(); + } + + startCam.put(); + + // Properly destroy the sprites here. + super.destroy(); + } + + override function destroy() { + if (onStageDestroy != null) onStageDestroy(this); + script?.call("destroy"); + destroySilently(); + } + + override function update(elapsed:Float) { + script?.call("update", [elapsed]); + super.update(elapsed); + script?.call("postUpdate", [elapsed]); + } + + override function draw() { + script?.call("draw"); + super.draw(); + script?.call("postDraw"); + } + + @:dox(hide) private inline function __isExtensionNode(node:Access):Bool { + return node.name == "use-extension" || node.name == "extension" || node.name == "ext"; + } + + @:dox(hide) private function checkMemoryMode(xml:Access, loadAll:Bool, elems:Array) { + for(node in xml.elements) { + if (node.name == "high-memory" && (!Options.lowMemoryMode || loadAll)) + for (e in node.elements) pushNode(e, elems); + else if (node.name == "low-memory" && (Options.lowMemoryMode || loadAll)) + for (e in node.elements) pushNode(e, elems); + else if (node.name == "layer") checkMemoryMode(node, loadAll, elems); // recursive check in layers + else pushNode(node, elems); + } + } + + @:dox(hide) private function pushNode(node:Access, elems:Array) { + elems.push(node); + if (__isExtensionNode(node) && XMLImportedScriptInfo.shouldLoadBefore(node)) + if (onPrepareInfo != null) onPrepareInfo(node); + } + + //region IHScriptCustomBehaviour implementation + override function hget(name:String):Dynamic { + if (__instanceFields.contains(name) || __instanceFields.contains('get_$name')) + return Reflect.getProperty(this, name); + + // We should check PlayState last, and check sub-layers before. + var og_val:Dynamic = super.hget(name); + if (og_val != null) return og_val; + + if (PlayState.instance != null && (PlayState.__instanceFields.contains(name) || PlayState.__instanceFields.contains('get_$name'))) + return Reflect.getProperty(PlayState.instance, name); + + return null; + } + + override function hset(name:String, val:Dynamic):Dynamic { + if (__instanceFields.contains(name) || __instanceFields.contains('set_$name')) { + Reflect.setProperty(this, name, val); + return val; + } + + var og_val:Dynamic = super.hget(name); + if (og_val != null) return og_val; + + if (PlayState.instance != null && (PlayState.__instanceFields.contains(name) || PlayState.__instanceFields.contains('set_$name'))) { + Reflect.setProperty(PlayState.instance, name, val); + return val; + } + return null; + } + //endregion + + // Backwards compatibility + public var stagePath(get, never):String; + public var stageFile(get, never):String; + public var stageName(get, set):String; + public var stageScript(get, never):Script; + public var characterPoses(get, never):Map; + + function get_stageScript():Script { return this.script; } + function get_stagePath():String { return this.xmlFilePath; } + function get_stageFile():String { return this.fileName; } + function get_stageName():String { return this.name; } + function set_stageName(name:String):String { return this.name = name; } + function get_characterPoses():Map { return this.characterPosLookup; } + inline function applyCharStuff(char:Character, posName:String, id:Float = 0) { return applyCharPos(char, posName, id); } +} diff --git a/source/funkin/game/PlayState.hx b/source/funkin/game/PlayState.hx index 8d28f34532..00d923120c 100644 --- a/source/funkin/game/PlayState.hx +++ b/source/funkin/game/PlayState.hx @@ -22,8 +22,10 @@ import funkin.backend.scripting.ScriptPack; import funkin.backend.scripting.events.*; import funkin.backend.scripting.events.gameplay.*; import funkin.backend.scripting.events.note.*; +import funkin.backend.scripting.events.stage.*; import funkin.backend.system.Conductor; import funkin.backend.system.RotatingSpriteGroup; +import funkin.backend.utils.XMLUtil.XMLImportedScriptInfo; import funkin.editors.SaveWarning; import funkin.editors.charter.Charter; import funkin.editors.charter.CharterSelection; @@ -35,6 +37,7 @@ import funkin.menus.*; import funkin.backend.week.WeekData; import funkin.savedata.FunkinSave; import haxe.io.Path; +import haxe.xml.Access; using StringTools; @@ -42,6 +45,7 @@ using StringTools; @:access(funkin.game.StrumLine) class PlayState extends MusicBeatState { + public static final __instanceFields = Type.getInstanceFields(PlayState); /** * Current PlayState instance. */ @@ -122,7 +126,7 @@ class PlayState extends MusicBeatState /** * Current Stage. */ - public var stage:Stage; + public var stage(default, set):NewStage; /** * Whenever the score will save when you beat the song. */ @@ -571,6 +575,44 @@ class PlayState extends MusicBeatState */ public var noteTypesArray:Array = [null]; + // Backwards compatability for removing sprites from the Stage... + @:dox(hide) override public function remove(basic:FlxBasic, splice:Bool = false):FlxBasic { + if (members == null) return null; + + final index = members.indexOf(basic); + + if (index < 0) return stage.remove(basic, splice); + return super.remove(basic, splice); + } + + private var CHARACTER_CACHE:Map> = []; + private function cache_character(character:Character, index:Int = 0):Character { + if (CHARACTER_CACHE.exists(character.curCharacter)) { + var stack:Array = CHARACTER_CACHE.get(character.curCharacter); + trace('stack[index]: ${stack[index]}'); + if (stack[index] != null) return stack[index]; + + stack.insert(index, character); + trace('Stacking Character ${character.curCharacter} at index $index'); + return character; + } + + CHARACTER_CACHE.set(character.curCharacter, [character]); + trace('Caching ${character.curCharacter} at index $index'); + + return character; + } + + private function get_character_cache(index:Int, name:String):Character { + if (!CHARACTER_CACHE.exists(name)) return cache_character(new Character(0, 0, name, false), index); + + var stack:Array = CHARACTER_CACHE[name]; + if (stack.length <= 0) return cache_character(new Character(0, 0, name, false), index); + + index = CoolUtil.boundInt(index, 0, stack.length - 1); + return stack[index]; + } + /** * Hit window, in milliseconds. A Legacy CNE Hit window configuration, * Don't use this, it's for mods that still uses the old judgement timing, instead use ratingManager. @@ -653,10 +695,10 @@ class PlayState extends MusicBeatState } private inline function get_curStage() - return stage == null ? "" : stage.stageName; + return stage == null ? "" : stage.name; private inline function set_curStage(name:String) { - if (stage != null) stage.stageName = name; + if (stage != null) stage.name = name; return name; } @@ -743,7 +785,7 @@ class PlayState extends MusicBeatState cameraFocusOffset = FlxPoint.get(); if (SONG.stage == null || SONG.stage.trim() == "") SONG.stage = Flags.DEFAULT_STAGE; - add(stage = new Stage(SONG.stage)); + add(stage = NewStage.cache(SONG.stage)); if (!chartingMode || Options.charterEnablePlaytestScripts) { switch(SONG.meta.name) { @@ -808,7 +850,7 @@ class PlayState extends MusicBeatState for(i=>strumLine in SONG.strumLines) { if (strumLine == null) continue; - var chars = []; + var chars:Array = []; var charPosName:String = strumLine.position == null ? (switch(strumLine.type) { case 0: "dad"; case 1: "boyfriend"; @@ -816,7 +858,7 @@ class PlayState extends MusicBeatState }) : strumLine.position; if (strumLine.characters != null) for(k=>charName in strumLine.characters) { var char = new Character(0, 0, charName, stage.isCharFlipped(stage.characterPoses[charName] != null ? charName : charPosName, strumLine.type == 1)); - stage.applyCharStuff(char, charPosName, k); + stage.applyCharPos(char, charPosName, k); chars.push(char); } @@ -958,6 +1000,36 @@ class PlayState extends MusicBeatState __updateNote_event = EventManager.get(NoteUpdateEvent); + for (event in events) { + switch(event.name) { + case "Change Character": + if (!event.params[5]) continue; + var strumline_index:Int = CoolUtil.boundInt(event.params[0], 0, strumLines.members.length - 1); + var char_idx:Int = event.params[1]; + var name:String = event.params[2]; + var override_isPlayer:Bool = event.params[3]; + + var strumline:StrumLine = (strumLines.members[strumline_index] ?? cpuStrums); + var character:Character = strumline.characters[char_idx]; + var isPlayer:Bool = (override_isPlayer) ? event.params[4] : (character?.isPlayer ?? false); + trace('character: ${character.curCharacter} ($name) | isPlayer: $isPlayer | override_isPlayer: $override_isPlayer | event.params[4]: ${event.params[4]}'); + + var path:String = Paths.xml('characters/$name'); + if (Assets.exists(path)) { + try { + var node:Access = new Access(Xml.parse(Assets.getText(path)).firstElement()); + + var folder:String = (node.has.sprite) ? node.att.sprite : name; + var iconPath:String = (node.has.icon) ? node.att.icon : name; + + graphicCache.cache(Paths.image('characters/$folder')); + graphicCache.cache(Paths.image('icons/$iconPath')); + } + catch (e) Logs.trace('Couldn\'t load character XML "$path": ${e.message}', ERROR); + } + cache_character(new Character(0, 0, name, isPlayer), char_idx); + } + } gameAndCharsCall("postCreate", null, "gamePostCreate"); } @@ -1124,6 +1196,7 @@ class PlayState extends MusicBeatState remove(stage, true); } + NewStage.clear_cache(); cameraFocusOffset.put(); scripts = FlxDestroyUtil.destroy(scripts); @@ -1155,13 +1228,15 @@ class PlayState extends MusicBeatState else events = [ for (e in songData.events) { switch (e.name) { - case "Camera Movement": if (!foundCam && e.time < 10) { - foundCam = true; - executeEvent(e); - } - case "Time Signature Change": if (!foundSigs && (e.params[0] != 4 || e.params[1] != 4)) { - foundSigs = true; - } + case "Camera Movement": + if (!foundCam && e.time < 10) { + foundCam = true; + executeEvent(e); + } + case "Time Signature Change": + if (!foundSigs && (e.params[0] != 4 || e.params[1] != 4)) foundSigs = true; + case "Change Stage": + NewStage.cache(e.params[0]); } e; } @@ -1715,6 +1790,56 @@ class PlayState extends MusicBeatState if (strumLines.members[event.params[0]] != null && strumLines.members[event.params[0]].characters != null) for (char in strumLines.members[event.params[0]].characters) if (char != null && char.hasAnim(event.params[1])) char.playAnim(event.params[1], event.params[2], event.params[3] == "NONE" ? null : event.params[3]); + + case "Change Stage": + var stage_name:String = event.params[0]; + var use_cache:Bool = event.params[1]; + var destroy_previous:Bool = event.params[2]; + var force_reload:Bool = event.params[3]; + + var new_stage:NewStage = use_cache ? NewStage.cache(stage_name, force_reload) : new NewStage(stage_name); + + if (destroy_previous) stage?.destroy(); + stage = new_stage; + + case "Change Character": + var strumline_index:Int = CoolUtil.boundInt(event.params[0], 0, strumLines.members.length - 1); + var char_index:Int = event.params[1]; + var char_name:String = event.params[2]; + + var override_isPlayer:Bool = event.params[3]; + var isPlayer:Bool = event.params[4]; + + var cached:Bool = event.params[5]; + + var destroy_previous:Bool = event.params[6]; + var auto_stage_position:Bool = event.params[7]; + + var strumline:StrumLine = (strumLines.members[strumline_index] ?? cpuStrums); + var character:Character = strumline.characters[char_index]; + if (character != null) { + var new_character:Character = (cached) ? get_character_cache(char_index, char_name) : new Character(character.x, character.y, char_name, false); + + new_character.isPlayer = (override_isPlayer) ? isPlayer : character.isPlayer; + new_character.__switchAnims = (strumline.data.type == 1); + new_character.fixChar(new_character.__switchAnims, new_character.__autoInterval); + if (new_character.isPlayer) new_character.flipX = !new_character.flipX; // to re-flip 😭 + new_character.__baseFlipped = new_character.flipX; + new_character.dance(); + + var icon:HealthIcon = (new_character.isPlayer) ? iconP1 : iconP2; + icon.setIcon(new_character.icon); + + strumline.characters[char_index] = new_character; + + stage.remove(character, true); + if (destroy_previous) character?.destroy(); + + if (auto_stage_position) reposition_characters(); + + } + + case "Unknown": // nothing } @@ -2284,7 +2409,7 @@ class PlayState extends MusicBeatState private inline function set_playerStrums(v:StrumLine):StrumLine return strumLines.members[1] = v; private inline function get_gfSpeed():Int - return (strumLines.members[2] != null && strumLines.members[2].characters[0] != null) ? strumLines.members[2].characters[0].beatInterval : 1; + return strumLines.members[2] != null ? strumLines?.members[2]?.characters[0]?.beatInterval : 1; private inline function set_gfSpeed(v:Int):Int { if (strumLines.members[2] != null && strumLines.members[2].characters[0] != null) strumLines.members[2].characters[0].beatInterval = v; @@ -2293,8 +2418,67 @@ class PlayState extends MusicBeatState private inline static function get_campaignAccuracy() return campaignAccuracyCount == 0 ? 0 : campaignAccuracyTotal / campaignAccuracyCount; + + // For now, I'd like the stage to manage all of this itself, but also it's completely fine if we have code here like `gameAndCharsEvent` for PlayState. + private function set_stage(new_stage:NewStage):NewStage { + var isReplacing:Bool = (this.stage != null); // check if there is already a stage + + new_stage.onStageScriptLoad = (script) -> scripts.add(script); + new_stage.onStartCamSet = (startCam, defaultZoom) -> { + camFollow.x = startCam.x; + camFollow.y = startCam.y; + defaultCamZoom = defaultZoom; + } + new_stage.onXMLLoaded = (stageEvent) -> { + return this.gameAndCharsEvent("onStageXMLParsed", stageEvent).elems; + } + new_stage.onRatingSet = (x, y) -> { + comboGroup.setPosition(x, y); + add(comboGroup); + return comboGroup; + } + new_stage.onPrepareInfo = (node) -> { + return XMLImportedScriptInfo.prepareInfos(node, this.scripts, (infos) -> { + new_stage.xmlImportedScripts.push(infos); + }); + } + new_stage.onRemoveInfo = (script) -> scripts.remove(script); + new_stage.onNodeLoaded = (node, sprite) -> { + return this.gameAndCharsEvent("onStageNodeParsed", EventManager.get(StageNodeEvent).recycle(new_stage, node, sprite, node.name)).sprite; + } + new_stage.onPostStageCreation = (stageEvent) -> this.gameAndCharsEvent("onPostStageCreation", stageEvent); + new_stage.onStageDestroy = (_) -> this.gameAndCharsCall("onStageDestroy", [_]); + + new_stage.active = new_stage.exists = new_stage.visible = true; + new_stage.loadStage(); + + if (!isReplacing) return this.stage = new_stage; + + replace(this.stage, new_stage); + this.stage = new_stage; + + reposition_characters(); + + return this.stage; + } #end + private function reposition_characters() { + // reset the characters positions with the new stage + for (strumline in this.strumLines.members) { + if (strumline.characters.length == 0) continue; + for (i => char in strumline.characters) { + var posName:String = strumline.data.position ?? switch(strumline.data.type) { + case 0: "dad"; + case 1: "boyfriend"; + case 2: "girlfriend"; + }; + char.isPlayer = this.stage.isCharFlipped(this.stage.characterPoses[char.curCharacter] != null ? char.curCharacter : posName, !strumline.opponentSide); + this.stage.applyCharPos(char, posName, i); + } + } + } + /** * Load a week into PlayState. * @param weekData Week Data