From 1a85b4a1cea61bc7442a5b1bc3a5b4aaaccbd09e Mon Sep 17 00:00:00 2001 From: Eric Myllyoja Date: Thu, 17 Mar 2022 01:40:08 -0400 Subject: [PATCH] First WIP of scripted characters (NOT WORKING) --- source/funkin/InitState.hx | 3 +- .../funkin/modding/base/ScriptedFlxSprite.hx | 5 +- .../modding/base/ScriptedFlxSpriteGroup.hx | 5 +- .../funkin/modding/module/ScriptedModule.hx | 5 +- source/funkin/play/AnimationData.hx | 56 +++ source/funkin/play/PlayState.hx | 113 +++-- source/funkin/play/character/Character.hx | 9 - source/funkin/play/character/CharacterBase.hx | 140 ++++++ source/funkin/play/character/CharacterData.hx | 414 ++++++++++++++++++ .../play/character/ScriptedCharacter.hx | 14 + .../play/character/render/PackerCharacter.hx | 15 + .../play/character/render/SparrowCharacter.hx | 15 + source/funkin/play/stage/Bopper.hx | 55 ++- source/funkin/play/stage/ScriptedBopper.hx | 5 +- source/funkin/play/stage/ScriptedStage.hx | 5 +- source/funkin/play/stage/Stage.hx | 54 ++- source/funkin/play/stage/StageData.hx | 67 +-- 17 files changed, 832 insertions(+), 148 deletions(-) create mode 100644 source/funkin/play/AnimationData.hx delete mode 100644 source/funkin/play/character/Character.hx create mode 100644 source/funkin/play/character/CharacterBase.hx create mode 100644 source/funkin/play/character/CharacterData.hx create mode 100644 source/funkin/play/character/ScriptedCharacter.hx create mode 100644 source/funkin/play/character/render/PackerCharacter.hx create mode 100644 source/funkin/play/character/render/SparrowCharacter.hx diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx index 78729e22a..c2b22c0f4 100644 --- a/source/funkin/InitState.hx +++ b/source/funkin/InitState.hx @@ -1,5 +1,6 @@ package funkin; +import funkin.play.character.CharacterData.CharacterDataParser; import funkin.modding.module.ModuleHandler; import funkin.play.stage.StageData; import funkin.charting.ChartingState; @@ -122,7 +123,7 @@ class InitState extends FlxTransitionableState FlxTransitionableState.skipNextTransIn = true; StageDataParser.loadStageCache(); - + CharacterDataParser.loadCharacterCache(); ModuleHandler.loadModuleCache(); #if song diff --git a/source/funkin/modding/base/ScriptedFlxSprite.hx b/source/funkin/modding/base/ScriptedFlxSprite.hx index 9bccdc778..d13d968a8 100644 --- a/source/funkin/modding/base/ScriptedFlxSprite.hx +++ b/source/funkin/modding/base/ScriptedFlxSprite.hx @@ -4,7 +4,4 @@ import flixel.FlxSprite; import funkin.modding.IHook; @:hscriptClass -class ScriptedFlxSprite extends FlxSprite implements IHook -{ - // No body needed for this class, it's magic ;) -} +class ScriptedFlxSprite extends FlxSprite implements IHook {} diff --git a/source/funkin/modding/base/ScriptedFlxSpriteGroup.hx b/source/funkin/modding/base/ScriptedFlxSpriteGroup.hx index a60dfdad3..5db61b81e 100644 --- a/source/funkin/modding/base/ScriptedFlxSpriteGroup.hx +++ b/source/funkin/modding/base/ScriptedFlxSpriteGroup.hx @@ -4,7 +4,4 @@ import flixel.group.FlxSpriteGroup; import funkin.modding.IHook; @:hscriptClass -class ScriptedFlxSpriteGroup extends FlxSpriteGroup implements IHook -{ - // No body needed for this class, it's magic ;) -} +class ScriptedFlxSpriteGroup extends FlxSpriteGroup implements IHook {} diff --git a/source/funkin/modding/module/ScriptedModule.hx b/source/funkin/modding/module/ScriptedModule.hx index 31c79addb..7369707e3 100644 --- a/source/funkin/modding/module/ScriptedModule.hx +++ b/source/funkin/modding/module/ScriptedModule.hx @@ -3,7 +3,4 @@ package funkin.modding.module; import funkin.modding.IHook; @:hscriptClass -class ScriptedModule extends Module implements IHook -{ - // No body needed for this class, it's magic ;) -} +class ScriptedModule extends Module implements IHook {} diff --git a/source/funkin/play/AnimationData.hx b/source/funkin/play/AnimationData.hx new file mode 100644 index 000000000..5a9586b9f --- /dev/null +++ b/source/funkin/play/AnimationData.hx @@ -0,0 +1,56 @@ +package funkin.play; + +typedef AnimationData = +{ + /** + * The name for the animation. + * This should match the animation name queried by the game; + * for example, characters need animations with names `idle`, `singDOWN`, `singUPmiss`, etc. + */ + var name:String; + + /** + * The prefix for the frames of the animation as defined by the XML file. + * This will may or may not differ from the `name` of the animation, + * depending on how your animator organized their FLA or whatever. + */ + var prefix:String; + + /** + * Offset the character's position by this amount when playing this animation. + * @default [0, 0] + */ + var offsets:Null>; + + /** + * Whether the animation should loop when it finishes. + * @default false + */ + var looped:Null; + + /** + * Whether the animation's sprites should be flipped horizontally. + * @default false + */ + var flipX:Null; + + /** + * Whether the animation's sprites should be flipped vertically. + * @default false + */ + var flipY:Null; + + /** + * The frame rate of the animation. + * @default 24 + */ + var frameRate:Null; + + /** + * If you want this animation to use only certain frames of an animation with a given prefix, + * select them here. + * @example [0, 1, 2, 3] (use only the first four frames) + * @default [] (all frames) + */ + var frameIndices:Null>; +} diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index 61ede4246..05353d155 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -1,6 +1,6 @@ package funkin.play; -import funkin.play.Strumline.StrumlineArrow; +import funkin.play.character.CharacterBase; import flixel.addons.effects.FlxTrail; import flixel.addons.transition.FlxTransitionableState; import flixel.FlxCamera; @@ -27,8 +27,10 @@ import funkin.modding.events.ScriptEventDispatcher; import funkin.modding.IHook; import funkin.modding.module.ModuleHandler; import funkin.Note; +import funkin.play.character.CharacterData; import funkin.play.stage.Stage; import funkin.play.stage.StageData; +import funkin.play.Strumline.StrumlineArrow; import funkin.play.Strumline.StrumlineStyle; import funkin.Section.SwagSection; import funkin.SongLoad.SwagSong; @@ -126,8 +128,11 @@ class PlayState extends MusicBeatState implements IHook /** * An empty FlxObject contained in the scene. * The current gameplay camera will be centered on this object. Tween its position to move the camera smoothly. + * + * NOTE: This must be an FlxObject, not an FlxPoint, because it needs to be added to the scene. + * Once it's added to the scene, the camera can be configured to follow it. */ - public var cameraFollowPoint:FlxObject; + public var cameraFollowPoint:FlxObject = new FlxObject(0, 0, 1, 1); /** * PRIVATE INSTANCE VARIABLES @@ -240,7 +245,6 @@ class PlayState extends MusicBeatState implements IHook var songScore:Int = 0; var doof:DialogueBox; var grpNoteSplashes:FlxTypedGroup; - var camPos:FlxPoint; var comboPopUps:PopUpStuff; var perfectMode:Bool = false; var previousFrameTime:Int = 0; @@ -315,6 +319,14 @@ class PlayState extends MusicBeatState implements IHook initDiscord(); #end + // Configure camera follow point. + if (previousCameraFollowPoint != null) + { + cameraFollowPoint.setPosition(previousCameraFollowPoint.x, previousCameraFollowPoint.y); + previousCameraFollowPoint = null; + } + add(cameraFollowPoint); + comboPopUps = new PopUpStuff(); add(comboPopUps); @@ -328,16 +340,6 @@ class PlayState extends MusicBeatState implements IHook generateSong(); - cameraFollowPoint = new FlxObject(0, 0, 1, 1); - cameraFollowPoint.setPosition(camPos.x, camPos.y); - - if (previousCameraFollowPoint != null) - { - cameraFollowPoint = previousCameraFollowPoint; - previousCameraFollowPoint = null; - } - - add(cameraFollowPoint); resetCamera(); FlxG.worldBounds.set(0, 0, FlxG.width, FlxG.height); @@ -467,7 +469,11 @@ class PlayState extends MusicBeatState implements IHook function initCharacters() { - // all dis is shitty, redo later for stage shit + // + // GIRLFRIEND + // + + // TODO: Tie the GF version to the song data, not the stage ID or the current player. var gfVersion:String = 'gf'; switch (currentStageId) @@ -483,35 +489,34 @@ class PlayState extends MusicBeatState implements IHook } if (currentSong.player1 == "pico") - { gfVersion = "nene"; - } if (currentSong.song.toLowerCase() == 'stress') gfVersion = 'pico-speaker'; - var gf = new Character(400, 130, gfVersion); - gf.scrollFactor.set(0.95, 0.95); - - switch (gfVersion) + var girlfriend:Character = new Character(350, -70, gfVersion); + girlfriend.scrollFactor.set(0.95, 0.95); + if (gfVersion == 'pico-speaker') { - case 'pico-speaker': - gf.x -= 50; - gf.y -= 200; + girlfriend.x -= 50; + girlfriend.y -= 200; } + // + // DAD + // var dad = new Character(100, 100, currentSong.player2); - camPos = new FlxPoint(dad.getGraphicMidpoint().x, dad.getGraphicMidpoint().y); + cameraFollowPoint.setPosition(dad.getGraphicMidpoint().x, dad.getGraphicMidpoint().y); switch (currentSong.player2) { case 'gf': - dad.setPosition(gf.x, gf.y); - gf.visible = false; + dad.setPosition(girlfriend.x, girlfriend.y); + girlfriend.visible = false; if (isStoryMode) { - camPos.x += 600; + cameraFollowPoint.x += 600; tweenCamIn(); } case "spooky": @@ -521,25 +526,40 @@ class PlayState extends MusicBeatState implements IHook case 'monster-christmas': dad.y += 130; case 'dad': - camPos.x += 400; + cameraFollowPoint.x += 400; case 'pico': - camPos.x += 600; + cameraFollowPoint.x += 600; dad.y += 300; case 'parents-christmas': dad.x -= 500; case 'senpai' | 'senpai-angry': dad.x += 150; dad.y += 360; - camPos.set(dad.getGraphicMidpoint().x + 300, dad.getGraphicMidpoint().y); + cameraFollowPoint.setPosition(dad.getGraphicMidpoint().x + 300, dad.getGraphicMidpoint().y); case 'spirit': dad.x -= 150; dad.y += 100; - camPos.set(dad.getGraphicMidpoint().x + 300, dad.getGraphicMidpoint().y); + cameraFollowPoint.setPosition(dad.getGraphicMidpoint().x + 300, dad.getGraphicMidpoint().y); case 'tankman': dad.y += 180; } - var boyfriend = new Boyfriend(770, 450, currentSong.player1); + if (currentSong.player1 == "pico") + { + dad.x -= 100; + dad.y -= 100; + } + + // + // BOYFRIEND + // + var boyfriend:CharacterBase; + switch (currentSong.player1) + { + default: + boyfriend = CharacterDataParser.fetchCharacter(currentSong.player1); + boyfriend.characterType = CharacterType.BF; + } // REPOSITIONING PER STAGE switch (currentStageId) @@ -550,8 +570,8 @@ class PlayState extends MusicBeatState implements IHook evilTrail.zIndex = 190; add(evilTrail); case "tank": - gf.y += 10; - gf.x -= 30; + girlfriend.y += 10; + girlfriend.x -= 30; boyfriend.x += 40; boyfriend.y += 0; dad.y += 60; @@ -559,8 +579,8 @@ class PlayState extends MusicBeatState implements IHook if (gfVersion != 'pico-speaker') { - gf.x -= 170; - gf.y -= 75; + girlfriend.x -= 170; + girlfriend.y -= 75; } } @@ -568,16 +588,16 @@ class PlayState extends MusicBeatState implements IHook { // We're using Eric's stage handler. // Characters get added to the stage, not the main scene. - currentStage.addCharacter(gf, GF); currentStage.addCharacter(boyfriend, BF); - currentStage.addCharacter(dad, DAD); + currentStage.addCharacterOld(girlfriend, GF); + currentStage.addCharacterOld(dad, DAD); // Redo z-indexes. currentStage.refresh(); } else { - add(gf); + add(girlfriend); add(dad); add(boyfriend); } @@ -612,6 +632,7 @@ class PlayState extends MusicBeatState implements IHook // Reload the stages in cache. This might cause a lag spike but who cares this is a debug utility. StageDataParser.loadStageCache(); + CharacterDataParser.loadCharacterCache(); ModuleHandler.loadModuleCache(); // Reload the level. This should use new data from the assets folder. @@ -685,8 +706,6 @@ class PlayState extends MusicBeatState implements IHook senpaiEvil.screenCenter(); senpaiEvil.x += senpaiEvil.width / 5; - cameraFollowPoint.setPosition(camPos.x, camPos.y); - if (currentSong.song.toLowerCase() == 'roses' || currentSong.song.toLowerCase() == 'thorns') { remove(black); @@ -1664,7 +1683,7 @@ class PlayState extends MusicBeatState implements IHook && PlayState.instance.currentStage.getBoyfriend().animation.curAnim.name.startsWith('sing') && !PlayState.instance.currentStage.getBoyfriend().animation.curAnim.name.endsWith('miss')) { - PlayState.instance.currentStage.getBoyfriend().playAnim('idle'); + PlayState.instance.currentStage.getBoyfriend().playAnimation('idle'); } } @@ -1695,7 +1714,7 @@ class PlayState extends MusicBeatState implements IHook vocals.volume = 0; FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2)); - currentStage.getBoyfriend().playAnim('sing' + direction.nameUpper + 'miss', true); + currentStage.getBoyfriend().playAnimation('sing' + direction.nameUpper + 'miss', true); } function goodNoteHit(note:Note):Void @@ -1708,7 +1727,7 @@ class PlayState extends MusicBeatState implements IHook popUpScore(note.data.strumTime, note); } - currentStage.getBoyfriend().playAnim('sing' + note.dirNameUpper, true); + currentStage.getBoyfriend().playAnimation('sing' + note.dirNameUpper, true); playerStrumline.getArrow(note.data.noteData).playAnimation('confirm', true); @@ -1800,7 +1819,7 @@ class PlayState extends MusicBeatState implements IHook if (curBeat % 2 == 0) { if (currentStage.getBoyfriend().animation != null && !currentStage.getBoyfriend().animation.curAnim.name.startsWith("sing")) - currentStage.getBoyfriend().playAnim('idle'); + currentStage.getBoyfriend().playAnimation('idle'); if (currentStage.getDad().animation != null && !currentStage.getDad().animation.curAnim.name.startsWith("sing")) currentStage.getDad().dance(); } @@ -1812,7 +1831,7 @@ class PlayState extends MusicBeatState implements IHook if (curBeat % 8 == 7 && currentSong.song == 'Bopeebo') { - currentStage.getBoyfriend().playAnim('hey', true); + currentStage.getBoyfriend().playAnimation('hey', true); } if (curBeat % 16 == 15 @@ -1821,7 +1840,7 @@ class PlayState extends MusicBeatState implements IHook && curBeat > 16 && curBeat < 48) { - currentStage.getBoyfriend().playAnim('hey', true); + currentStage.getBoyfriend().playAnimation('hey', true); currentStage.getDad().playAnim('cheer', true); } } diff --git a/source/funkin/play/character/Character.hx b/source/funkin/play/character/Character.hx deleted file mode 100644 index c5ae1014a..000000000 --- a/source/funkin/play/character/Character.hx +++ /dev/null @@ -1,9 +0,0 @@ -package funkin.play.character; - -enum CharacterType -{ - BF; - GF; - DAD; - OTHER; -} diff --git a/source/funkin/play/character/CharacterBase.hx b/source/funkin/play/character/CharacterBase.hx new file mode 100644 index 000000000..22d842fa6 --- /dev/null +++ b/source/funkin/play/character/CharacterBase.hx @@ -0,0 +1,140 @@ +package funkin.play.character; + +import funkin.modding.events.ScriptEvent; +import funkin.modding.events.ScriptEvent.UpdateScriptEvent; +import funkin.play.character.CharacterData.CharacterDataParser; +import funkin.Note.NoteDir; +import funkin.modding.events.ScriptEvent.NoteScriptEvent; +import funkin.play.stage.Bopper; + +/** + * A Character is a stage prop which bops to the music as well as controlled by the strumlines. + * + * Remember: The character's origin is at its FEET. (horizontal center, vertical bottom) + */ +class CharacterBase extends Bopper +{ + public var characterId(default, null):String; + public var characterName(default, null):String; + + /** + * Whether the player is an active character (Boyfriend) or not. + */ + public var characterType:CharacterType = OTHER; + + public var attachedStrumlines(default, null):Array; + + final _data:CharacterData; + + /** + * Tracks how long, in seconds, the character has been playing the current `sing` animation. + * This is used to ensure that characters play the `sing` animations for at least one beat, + * preventing them from reverting to the `idle` animation between notes. + */ + public var holdTimer:Float = 0; + + final singTimeCrochet:Float; + + public function new(id:String) + { + super(); + this.characterId = id; + this.attachedStrumlines = []; + + _data = CharacterDataParser.parseCharacterData(this.characterId); + if (_data == null) + { + throw 'Could not find character data for characterId: $characterId'; + } + else + { + this.characterName = _data.name; + this.singTimeCrochet = _data.singTime; + } + } + + public override function onUpdate(event:UpdateScriptEvent):Void + { + super.onUpdate(event); + + // Handle character note hold time. + holdTimer += event.elapsed; + var singTimeMs:Float = singTimeCrochet * Conductor.crochet; + // Without this check here, the player character would only play the `sing` animation + // for one beat, as opposed to holding it as long as the player is holding the button. + var shouldStopSinging:Bool = (this.characterType == BF) ? !isHoldingNote() : true; + + if (holdTimer > singTimeMs && shouldStopSinging) + { + holdTimer = 0; + dance(); + } + } + + /** + * Returns true if the player is holding a note. + * Used when determing whether a the player character should revert to the `idle` animation. + * On non-player characters, this should be ignored. + */ + function isHoldingNote(player:Int = 1):Bool + { + // Returns true if at least one of LEFT, DOWN, UP, or RIGHT is being held. + switch (player) + { + case 1: + return [ + PlayerSettings.player1.controls.NOTE_LEFT, + PlayerSettings.player1.controls.NOTE_DOWN, + PlayerSettings.player1.controls.NOTE_UP, + PlayerSettings.player1.controls.NOTE_RIGHT, + ].contains(true); + case 2: + return [ + PlayerSettings.player2.controls.NOTE_LEFT, + PlayerSettings.player2.controls.NOTE_DOWN, + PlayerSettings.player2.controls.NOTE_UP, + PlayerSettings.player2.controls.NOTE_RIGHT, + ].contains(true); + } + return false; + } + + /** + * Every time a note is hit, check if the note is from the same strumline. + * If it is, then play the sing animation. + */ + public override function onNoteHit(event:NoteScriptEvent) + { + super.onNoteHit(event); + // If event.note is from the same strumline as this character, then sing. + // if (this.attachedStrumlines.indexOf(event.note.strumline) != -1) + // { + // this.playSingAnimation(event.note.dir, false, note.alt); + // } + } + + public override function onDestroy(event:ScriptEvent):Void + { + this.characterType = OTHER; + } + + /** + * Play the appropriate singing animation, for the given note direction. + * @param dir The direction of the note. + * @param miss If true, play the miss animation instead of the sing animation. + * @param suffix A suffix to append to the animation name, like `alt`. + */ + function playSingAnimation(dir:NoteDir, miss:Bool = false, suffix:String = ""):Void + { + var anim:String = 'sing${dir.nameUpper}${miss ? 'miss' : ''}${suffix != "" ? '-${suffix}' : ''}'; + playAnimation(anim, true); + } +} + +enum CharacterType +{ + BF; + DAD; + GF; + OTHER; +} diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx new file mode 100644 index 000000000..6affbc765 --- /dev/null +++ b/source/funkin/play/character/CharacterData.hx @@ -0,0 +1,414 @@ +package funkin.play.character; + +import openfl.Assets; +import haxe.Json; +import funkin.play.character.render.PackerCharacter; +import funkin.play.character.render.SparrowCharacter; +import funkin.util.assets.DataAssets; +import funkin.play.character.CharacterBase; +import funkin.play.character.ScriptedCharacter.ScriptedSparrowCharacter; +import funkin.play.character.ScriptedCharacter.ScriptedPackerCharacter; +import flixel.util.typeLimit.OneOfTwo; + +using StringTools; + +class CharacterDataParser +{ + /** + * The current version string for the stage data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateStageData()` function. + */ + public static final CHARACTER_DATA_VERSION:String = "1.0"; + + static final characterCache:Map = new Map(); + + static final DEFAULT_CHAR_ID:String = 'UNKNOWN'; + + /** + * Parses and preloads the game's stage data and scripts when the game starts. + * + * If you want to force stages to be reloaded, you can just call this function again. + */ + public static function loadCharacterCache():Void + { + // Clear any stages that are cached if there were any. + clearCharacterCache(); + trace("[CHARDATA] Loading character cache..."); + + // + // SCRIPTED CHARACTERS + // + + // Generic (Sparrow) characters + var scriptedCharClassNames:Array = ScriptedCharacter.listScriptClasses(); + trace(' Instantiating ${scriptedCharClassNames.length} scripted characters...'); + for (charCls in scriptedCharClassNames) + { + _storeChar(ScriptedCharacter.init(charCls, DEFAULT_CHAR_ID), charCls); + } + + // Sparrow characters + scriptedCharClassNames = ScriptedSparrowCharacter.listScriptClasses(); + if (scriptedCharClassNames.length > 0) + { + trace(' Instantiating ${scriptedCharClassNames.length} scripted characters (SPARROW)...'); + for (charCls in scriptedCharClassNames) + { + _storeChar(ScriptedSparrowCharacter.init(charCls, DEFAULT_CHAR_ID), charCls); + } + } + + // // Packer characters + // scriptedCharClassNames = ScriptedPackerCharacter.listScriptClasses(); + // if (scriptedCharClassNames.length > 0) + // { + // trace(' Instantiating ${scriptedCharClassNames.length} scripted characters (PACKER)...'); + // for (charCls in scriptedCharClassNames) + // { + // _storeChar(ScriptedPackerCharacter.init(charCls, DEFAULT_CHAR_ID), charCls); + // } + // } + + // TODO: Add more character types. + + // + // UNSCRIPTED STAGES + // + var charIdList:Array = DataAssets.listDataFilesInPath('characters/'); + var unscriptedCharIds:Array = charIdList.filter(function(charId:String):Bool + { + return !characterCache.exists(charId); + }); + trace(' Instantiating ${unscriptedCharIds.length} non-scripted characters...'); + for (charId in unscriptedCharIds) + { + var char:CharacterBase = null; + try + { + var charData:CharacterData = parseCharacterData(charId); + if (charData != null) + { + switch (charData.renderType) + { + case CharacterRenderType.PACKER: + char = new PackerCharacter(charId); + case CharacterRenderType.SPARROW: + // default + char = new SparrowCharacter(charId); + default: + trace(' Failed to instantiate character: ${charId} (Bad render type ${charData.renderType})'); + } + } + if (char != null) + { + trace(' Loaded character data: ${char.characterName}'); + characterCache.set(charId, char); + } + } + catch (e) + { + // Assume error was already logged. + continue; + } + } + + trace(' Successfully loaded ${Lambda.count(characterCache)} stages.'); + } + + static function _storeChar(char:CharacterBase, charCls:String):Void + { + if (char != null) + { + trace(' Loaded scripted character: ${char.characterName}'); + // Disable the rendering logic for stage until it's loaded. + // Note that kill() =/= destroy() + char.kill(); + + // Then store it. + characterCache.set(char.characterId, char); + } + else + { + trace(' Failed to instantiate scripted character class: ${charCls}'); + } + } + + public static function fetchCharacter(charId:String):Null + { + if (characterCache.exists(charId)) + { + trace('[CHARDATA] Successfully fetch stage: ${charId}'); + var character:CharacterBase = characterCache.get(charId); + character.revive(); + return character; + } + else + { + trace('[CHARDATA] Failed to fetch character, not found in cache: ${charId}'); + return null; + } + } + + static function clearCharacterCache():Void + { + if (characterCache != null) + { + for (char in characterCache) + { + char.destroy(); + } + characterCache.clear(); + } + } + + /** + * Load a character's JSON file, parse its data, and return it. + * + * @param charId The character to load. + * @return The character data, or null if validation failed. + */ + public static function parseCharacterData(charId:String):Null + { + var rawJson:String = loadCharacterFile(charId); + + var charData:CharacterData = migrateCharacterData(rawJson, charId); + + return validateCharacterData(charId, charData); + } + + static function loadCharacterFile(charPath:String):String + { + var charFilePath:String = Paths.json('characters/${charPath}'); + var rawJson = Assets.getText(charFilePath).trim(); + + while (!rawJson.endsWith("}")) + { + rawJson = rawJson.substr(0, rawJson.length - 1); + } + + return rawJson; + } + + static function migrateCharacterData(rawJson:String, charId:String) + { + // If you update the character data format in a breaking way, + // handle migration here by checking the `version` value. + + try + { + var charData:CharacterData = cast Json.parse(rawJson); + return charData; + } + catch (e) + { + trace(' Error parsing data for character: ${charId}'); + trace(' ${e}'); + return null; + } + } + + static final DEFAULT_NAME:String = "Untitled Character"; + static final DEFAULT_RENDERTYPE:CharacterRenderType = CharacterRenderType.SPARROW; + static final DEFAULT_STARTINGANIM:String = "idle"; + static final DEFAULT_SCROLL:Array = [0, 0]; + static final DEFAULT_ISPIXEL:Bool = false; + static final DEFAULT_DANCEEVERY:Int = 1; + static final DEFAULT_FRAMERATE:Int = 24; + static final DEFAULT_FLIPX:Bool = false; + static final DEFAULT_SCALE:Float = 1; + static final DEFAULT_FLIPY:Bool = false; + static final DEFAULT_LOOP:Bool = false; + static final DEFAULT_FRAMEINDICES:Array = []; + + /** + * Set unspecified parameters to their defaults. + * If the parameter is mandatory, print an error message. + * @param id + * @param input + * @return The validated character data + */ + static function validateCharacterData(id:String, input:CharacterData):Null + { + if (input == null) + { + trace('[CHARDATA] ERROR: Could not parse character data for "${id}".'); + return null; + } + + if (input.version == null) + { + trace('[CHARDATA] ERROR: Could not load character data for "$id": missing version'); + return null; + } + + if (input.version == CHARACTER_DATA_VERSION) + { + trace('[CHARDATA] ERROR: Could not load character data for "$id": bad/outdated version (got ${input.version}, expected ${CHARACTER_DATA_VERSION})'); + return null; + } + + if (input.name == null) + { + trace('[CHARDATA] WARN: Character data for "$id" missing name'); + input.name = DEFAULT_NAME; + } + + if (input.renderType == null) + { + input.renderType = DEFAULT_RENDERTYPE; + } + + if (input.assetPath == null) + { + trace('[CHARDATA] ERROR: Could not load character data for "$id": missing assetPath'); + return null; + } + + if (input.startingAnimation == null) + { + input.startingAnimation = DEFAULT_STARTINGANIM; + } + + if (input.scale == null) + { + input.scale = DEFAULT_SCALE; + } + + if (input.isPixel == null) + { + input.isPixel = DEFAULT_ISPIXEL; + } + + if (input.danceEvery == null) + { + input.danceEvery = DEFAULT_DANCEEVERY; + } + + if (input.animations == null || input.animations.length == 0) + { + trace('[CHARDATA] ERROR: Could not load character data for "$id": missing animations'); + input.animations = []; + } + + if (input.animations.length == 0 && input.startingAnimation != null) + { + return null; + } + + for (inputAnimation in input.animations) + { + if (inputAnimation.name == null) + { + trace('[CHARDATA] ERROR: Could not load character data for "$id": missing animation name for prop "${input.name}"'); + return null; + } + + if (inputAnimation.frameRate == null) + { + inputAnimation.frameRate = DEFAULT_FRAMERATE; + } + + if (inputAnimation.frameIndices == null) + { + inputAnimation.frameIndices = DEFAULT_FRAMEINDICES; + } + + if (inputAnimation.looped == null) + { + inputAnimation.looped = DEFAULT_LOOP; + } + + if (inputAnimation.flipX == null) + { + inputAnimation.flipX = DEFAULT_FLIPX; + } + + if (inputAnimation.flipY == null) + { + inputAnimation.flipY = DEFAULT_FLIPY; + } + } + + // All good! + return input; + } +} + +enum abstract CharacterRenderType(String) from String to String +{ + var SPARROW = 'sparrow'; + var PACKER = 'packer'; + // TODO: Aesprite? + // TODO: Animate? + // TODO: Experimental... +} + +typedef CharacterData = +{ + /** + * The sematic version of the chart data format. + */ + var version:String; + + /** + * The readable name of the character. + */ + var name:String; + + /** + * The type of rendering system to use for the character. + * @default sparrow + */ + var renderType:CharacterRenderType; + + /** + * Behavior varies by render type: + * - SPARROW: Path to retrieve both the spritesheet and the XML data from. + * - PACKER: Path to retrieve both the spritsheet and the TXT data from. + */ + var assetPath:String; + + /** + * Either the scale of the graphic as a float, or the [w, h] scale as an array of two floats. + * Pro tip: On pixel-art levels, save the sprites small and set this value to 6 or so to save memory. + * @default 1 + */ + var scale:OneOfTwo>; + + /** + * Setting this to true disables anti-aliasing for the character. + * @default false + */ + var isPixel:Null; + + /** + * The frequency at which the character will play its idle animation, in beats. + * Increasing this number will make the character dance less often. + * + * @default 1 + */ + var danceEvery:Null; + + /** + * The minimum duration that a character will play a note animation for, in beats. + * If this number is too low, you may see the character start playing the idle animation between notes. + * If this number is too high, you may see the the character play the sing animation for too long after the notes are gone. + * + * Examples: + * - Daddy Dearest uses a value of `1.525`. + * @default 1.0 + */ + var singTime:Null; + + /** + * An optional array of animations which the character can play. + */ + var animations:Array; + + /** + * If animations are used, this is the name of the animation to play first. + * @default idle + */ + var startingAnimation:Null; +}; diff --git a/source/funkin/play/character/ScriptedCharacter.hx b/source/funkin/play/character/ScriptedCharacter.hx new file mode 100644 index 000000000..b182b283d --- /dev/null +++ b/source/funkin/play/character/ScriptedCharacter.hx @@ -0,0 +1,14 @@ +package funkin.play.character; + +import funkin.play.character.render.PackerCharacter; +import funkin.play.character.render.SparrowCharacter; +import funkin.modding.IHook; + +@:hscriptClass +class ScriptedCharacter extends SparrowCharacter implements IHook {} + +@:hscriptClass +class ScriptedSparrowCharacter extends SparrowCharacter implements IHook {} + +@:hscriptClass +class ScriptedPackerCharacter extends PackerCharacter implements IHook {} diff --git a/source/funkin/play/character/render/PackerCharacter.hx b/source/funkin/play/character/render/PackerCharacter.hx new file mode 100644 index 000000000..cc9e82709 --- /dev/null +++ b/source/funkin/play/character/render/PackerCharacter.hx @@ -0,0 +1,15 @@ +package funkin.play.character.render; + +import funkin.play.character.CharacterBase.CharacterType; + +/** + * A PackerCharacter is a Character which is rendered by + * displaying an animation derived from a Packer spritesheet file. + */ +class PackerCharacter extends CharacterBase +{ + public function new(id:String) + { + super(id); + } +} diff --git a/source/funkin/play/character/render/SparrowCharacter.hx b/source/funkin/play/character/render/SparrowCharacter.hx new file mode 100644 index 000000000..3b2de6e92 --- /dev/null +++ b/source/funkin/play/character/render/SparrowCharacter.hx @@ -0,0 +1,15 @@ +package funkin.play.character.render; + +import funkin.play.character.CharacterBase.CharacterType; + +/** + * A SparrowCharacter is a Character which is rendered by + * displaying an animation derived from a SparrowV2 atlas spritesheet file. + */ +class SparrowCharacter extends CharacterBase +{ + public function new(id:String) + { + super(id); + } +} diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx index ca1d26ce0..632c57f3c 100644 --- a/source/funkin/play/stage/Bopper.hx +++ b/source/funkin/play/stage/Bopper.hx @@ -16,6 +16,7 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass { /** * The bopper plays the dance animation once every `danceEvery` beats. + * Set to 0 to disable idle animation. */ public var danceEvery:Int = 1; @@ -31,11 +32,19 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass /** * Set this value to define an additional horizontal offset to this sprite's position. */ - public var xOffset:Float = 0; + public var xOffset(default, set):Float = 0; override function set_x(value:Float):Float { - this.x = value + this.xOffset; + this.x = this.xOffset + value; + return this.x; + } + + function set_xOffset(value:Float):Float + { + var diff = value - this.xOffset; + this.xOffset = value; + this.x += diff; return value; } @@ -55,7 +64,15 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass override function set_y(value:Float):Float { - this.y = value + this.yOffset; + this.y = this.yOffset + value; + return this.y; + } + + function set_yOffset(value:Float):Float + { + var diff = value - this.yOffset; + this.yOffset = value; + this.y += diff; return value; } @@ -73,7 +90,7 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass function update_shouldAlternate():Void { - if (this.animation.getByName('danceLeft') != null) + if (hasAnimation('danceLeft')) { this.shouldAlternate = true; } @@ -84,7 +101,7 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass */ public function onBeatHit(event:SongTimeScriptEvent):Void { - if (event.beat % danceEvery == 0) + if (danceEvery > 0 && event.beat % danceEvery == 0) { dance(); } @@ -109,20 +126,42 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass { if (hasDanced) { - this.animation.play('danceRight$idleSuffix'); + playAnimation('danceRight$idleSuffix'); } else { - this.animation.play('danceLeft$idleSuffix'); + playAnimation('danceLeft$idleSuffix'); } hasDanced = !hasDanced; } else { - this.animation.play('idle$idleSuffix'); + playAnimation('idle$idleSuffix'); } } + public function hasAnimation(id:String):Bool + { + return this.animation.getByName(id) != null; + } + + /* + * @param AnimName The string name of the animation you want to play. + * @param Force Whether to force the animation to restart. + */ + public function playAnimation(name:String, force:Bool = false):Void + { + this.animation.play(name, force, false, 0); + } + + /** + * Returns the name of the animation that is currently playing. + */ + public function getCurrentAnimation():String + { + return this.animation.curAnim.name; + } + public function onScriptEvent(event:ScriptEvent) {} public function onCreate(event:ScriptEvent) {} diff --git a/source/funkin/play/stage/ScriptedBopper.hx b/source/funkin/play/stage/ScriptedBopper.hx index a344b0428..14e7644da 100644 --- a/source/funkin/play/stage/ScriptedBopper.hx +++ b/source/funkin/play/stage/ScriptedBopper.hx @@ -4,7 +4,4 @@ import funkin.modding.IHook; @:hscriptClass @:keep -class ScriptedBopper extends Bopper implements IHook -{ - // No body needed for this class, it's magic ;) -} +class ScriptedBopper extends Bopper implements IHook {} diff --git a/source/funkin/play/stage/ScriptedStage.hx b/source/funkin/play/stage/ScriptedStage.hx index 114afb1d5..e6a92d8b8 100644 --- a/source/funkin/play/stage/ScriptedStage.hx +++ b/source/funkin/play/stage/ScriptedStage.hx @@ -3,7 +3,4 @@ package funkin.play.stage; import funkin.modding.IHook; @:hscriptClass -class ScriptedStage extends Stage implements IHook -{ - // No body needed for this class, it's magic ;) -} +class ScriptedStage extends Stage implements IHook {} diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx index a17f340d4..233e02a66 100644 --- a/source/funkin/play/stage/Stage.hx +++ b/source/funkin/play/stage/Stage.hx @@ -1,5 +1,6 @@ package funkin.play.stage; +import funkin.play.character.CharacterBase; import funkin.modding.events.ScriptEventDispatcher; import funkin.modding.events.ScriptEvent; import funkin.modding.events.ScriptEvent.CountdownScriptEvent; @@ -10,7 +11,7 @@ import flixel.group.FlxSpriteGroup; import flixel.math.FlxPoint; import flixel.util.FlxSort; import funkin.modding.IHook; -import funkin.play.character.Character.CharacterType; +import funkin.play.character.CharacterBase.CharacterType; import funkin.play.stage.StageData.StageDataParser; import funkin.util.SortUtil; @@ -29,7 +30,8 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte public var camZoom:Float = 1.0; var namedProps:Map = new Map(); - var characters:Map = new Map(); + var characters:Map = new Map(); + var charactersOld:Map = new Map(); var boppers:Array = new Array(); /** @@ -149,12 +151,12 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte { if (propAnim.frameIndices.length == 0) { - propSprite.animation.addByPrefix(propAnim.name, propAnim.prefix, propAnim.frameRate, propAnim.loop, propAnim.flipX, + propSprite.animation.addByPrefix(propAnim.name, propAnim.prefix, propAnim.frameRate, propAnim.looped, propAnim.flipX, propAnim.flipY); } else { - propSprite.animation.addByIndices(propAnim.name, propAnim.prefix, propAnim.frameIndices, "", propAnim.frameRate, propAnim.loop, + propSprite.animation.addByIndices(propAnim.name, propAnim.prefix, propAnim.frameIndices, "", propAnim.frameRate, propAnim.looped, propAnim.flipX, propAnim.flipY); } } @@ -234,7 +236,7 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte /** * Used by the PlayState to add a character to the stage. */ - public function addCharacter(character:Character, charType:CharacterType) + public function addCharacter(character:CharacterBase, charType:CharacterType) { // Apply position and z-index. switch (charType) @@ -255,7 +257,38 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte character.x = _data.characters.dad.position[0]; character.y = _data.characters.dad.position[1]; default: - this.characters.set(character.curCharacter, character); + this.characters.set(character.characterId, character); + } + + // Add the character to the scene. + this.add(character); + } + + /** + * Used by the PlayState to add a character to the stage. + */ + public function addCharacterOld(character:Character, charType:CharacterType) + { + // Apply position and z-index. + switch (charType) + { + case BF: + this.charactersOld.set("bf", character); + character.zIndex = _data.characters.bf.zIndex; + character.x = _data.characters.bf.position[0]; + character.y = _data.characters.bf.position[1]; + case GF: + this.charactersOld.set("gf", character); + character.zIndex = _data.characters.gf.zIndex; + character.x = _data.characters.gf.position[0]; + character.y = _data.characters.gf.position[1]; + case DAD: + this.charactersOld.set("dad", character); + character.zIndex = _data.characters.dad.zIndex; + character.x = _data.characters.dad.position[0]; + character.y = _data.characters.dad.position[1]; + default: + this.charactersOld.set(character.curCharacter, character); } // Add the character to the scene. @@ -265,24 +298,25 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte /** * Retrieves a given character from the stage. */ - public function getCharacter(id:String):Character + public function getCharacter(id:String):CharacterBase { return this.characters.get(id); } - public function getBoyfriend():Character + public function getBoyfriend():CharacterBase { return getCharacter('bf'); + // return this.charactersOld.get('bf'); } public function getGirlfriend():Character { - return getCharacter('gf'); + return this.charactersOld.get('gf'); } public function getDad():Character { - return getCharacter('dad'); + return this.charactersOld.get('dad'); } /** diff --git a/source/funkin/play/stage/StageData.hx b/source/funkin/play/stage/StageData.hx index 2e9058a87..bc9946509 100644 --- a/source/funkin/play/stage/StageData.hx +++ b/source/funkin/play/stage/StageData.hx @@ -1,9 +1,9 @@ package funkin.play.stage; -import openfl.Assets; +import flixel.util.typeLimit.OneOfTwo; import funkin.util.assets.DataAssets; import haxe.Json; -import flixel.util.typeLimit.OneOfTwo; +import openfl.Assets; using StringTools; @@ -194,12 +194,18 @@ class StageDataParser return null; } - if (input.version != STAGE_DATA_VERSION) + if (input.version == null) { trace('[STAGEDATA] ERROR: Could not load stage data for "$id": missing version'); return null; } + if (input.version != STAGE_DATA_VERSION) + { + trace('[STAGEDATA] ERROR: Could not load stage data for "$id": bad/outdated version (got ${input.version}, expected ${STAGE_DATA_VERSION})'); + return null; + } + if (input.name == null) { trace('[STAGEDATA] WARN: Stage data for "$id" missing name'); @@ -301,9 +307,9 @@ class StageDataParser inputAnimation.frameIndices = DEFAULT_FRAMEINDICES; } - if (inputAnimation.loop == null) + if (inputAnimation.looped == null) { - inputAnimation.loop = true; + inputAnimation.looped = true; } if (inputAnimation.flipX == null) @@ -432,7 +438,7 @@ typedef StageDataProp = * An optional array of animations which the prop can play. * @default Prop has no animations. */ - var animations:Array; + var animations:Array; /** * If animations are used, this is the name of the animation to play first. @@ -448,52 +454,6 @@ typedef StageDataProp = var animType:String; }; -typedef StageDataPropAnimation = -{ - /** - * The name of the animation. - */ - var name:String; - - /** - * The common beginning of image names in atlas for this animation's frames. - * For example, if the frames are named "test0001.png", "test0002.png", etc., use "test". - */ - var prefix:String; - - /** - * If you want this animation to use only certain frames of an animation with a given prefix, - * select them here. - * @example [0, 1, 2, 3] (use only the first four frames) - * @default [] (all frames) - */ - var frameIndices:Array; - - /** - * The speed of the animation in frames per second. - * @default 24 - */ - var frameRate:Null; - - /** - * Whether the animation should loop. - * @default false - */ - var loop:Null; - - /** - * Whether to flip the sprite horizontally while animating. - * @default false - */ - var flipX:Null; - - /** - * Whether to flip the sprite vertically while animating. - * @default false - */ - var flipY:Null; -}; - typedef StageDataCharacter = { /** @@ -505,5 +465,6 @@ typedef StageDataCharacter = /** * The position to render the character at. - */ position:Array + */ + position:Array };