package funkin.play.cutscene.dialogue; import flixel.FlxSprite; import funkin.data.IRegistryEntry; import flixel.group.FlxSpriteGroup; import flixel.graphics.frames.FlxFramesCollection; import funkin.graphics.FunkinSprite; import flixel.addons.text.FlxTypeText; import funkin.util.assets.FlxAnimationUtil; import funkin.modding.events.ScriptEvent; import funkin.audio.FunkinSound; import funkin.modding.IScriptedClass.IDialogueScriptedClass; import flixel.util.FlxColor; import funkin.data.dialogue.dialoguebox.DialogueBoxData; import funkin.data.dialogue.dialoguebox.DialogueBoxRegistry; class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass implements IRegistryEntry<DialogueBoxData> { public final id:String; public var dialogueBoxName(get, never):String; function get_dialogueBoxName():String { return _data.name ?? 'UNKNOWN'; } public final _data:DialogueBoxData; /** * Offset the speaker's sprite by this much when playing each animation. */ var animationOffsets:Map<String, Array<Float>> = new Map<String, Array<Float>>(); /** * The current animation offset being used. */ var animOffsets(default, set):Array<Float> = [0, 0]; function set_animOffsets(value:Array<Float>):Array<Float> { if (animOffsets == null) animOffsets = [0, 0]; if ((animOffsets[0] == value[0]) && (animOffsets[1] == value[1])) return value; var xDiff:Float = value[0] - animOffsets[0]; var yDiff:Float = value[1] - animOffsets[1]; this.x += xDiff; this.y += yDiff; return animOffsets = value; } /** * The offset of the speaker overall. */ public var globalOffsets(default, set):Array<Float> = [0, 0]; function set_globalOffsets(value:Array<Float>):Array<Float> { if (globalOffsets == null) globalOffsets = [0, 0]; if (globalOffsets == value) return value; var xDiff:Float = value[0] - globalOffsets[0]; var yDiff:Float = value[1] - globalOffsets[1]; this.x += xDiff; this.y += yDiff; return globalOffsets = value; } var boxSprite:FlxSprite; var textDisplay:FlxTypeText; var text(default, set):String; function set_text(value:String):String { this.text = value; textDisplay.resetText(this.text); textDisplay.start(); return this.text; } public var speed(default, set):Float; function set_speed(value:Float):Float { this.speed = value; textDisplay.delay = this.speed * 0.05; // 1.0 x 0.05 return this.speed; } public function new(id:String) { super(); this.id = id; this._data = _fetchData(id); if (_data == null) { throw 'Could not parse dialogue box data for id: $id'; } } public function onCreate(event:ScriptEvent):Void { this.globalOffsets = [0, 0]; this.x = 0; this.y = 0; this.alpha = 1; loadSpritesheet(); loadAnimations(); loadText(); } function loadSpritesheet():Void { if (this.boxSprite != null) { remove(this.boxSprite); this.boxSprite = null; } this.boxSprite = new FunkinSprite(0, 0); trace('[DIALOGUE BOX] Loading spritesheet ${_data.assetPath} for ${id}'); var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath); if (tex == null) { trace('Could not load Sparrow sprite: ${_data.assetPath}'); return; } this.boxSprite.frames = tex; if (_data.isPixel) { this.boxSprite.antialiasing = false; } else { this.boxSprite.antialiasing = true; } this.flipX = _data.flipX; this.flipY = _data.flipY; this.globalOffsets = _data.offsets; this.setScale(_data.scale); add(this.boxSprite); } public function setText(newText:String):Void { textDisplay.prefix = ''; textDisplay.resetText(newText); textDisplay.start(); } public function appendText(newText:String):Void { textDisplay.prefix = this.textDisplay.text; textDisplay.resetText(newText); textDisplay.start(); } public function skip():Void { textDisplay.skip(); } /** * Reassign this to set a callback. */ function onTypingComplete():Void { // No save navigation? :( if (typingCompleteCallback != null) typingCompleteCallback(); } public var typingCompleteCallback:() -> Void; /** * Set the sprite scale to the appropriate value. * @param scale */ public function setScale(scale:Null<Float>):Void { if (scale == null) scale = 1.0; this.boxSprite.scale.x = scale; this.boxSprite.scale.y = scale; this.boxSprite.updateHitbox(); } /** * Calls `kill()` on the group's members and then on the group itself. * You can revive this group later via `revive()` after this. */ public override function kill():Void { super.kill(); if (this.boxSprite != null) { this.boxSprite.kill(); this.boxSprite = null; } if (this.textDisplay != null) { this.textDisplay.kill(); this.textDisplay = null; } this.clear(); } public override function revive():Void { super.revive(); this.visible = true; this.alpha = 1.0; } function loadAnimations():Void { trace('[DIALOGUE BOX] Loading ${_data.animations.length} animations for ${id}'); FlxAnimationUtil.addAtlasAnimations(this.boxSprite, _data.animations); for (anim in _data.animations) { if (anim.offsets == null) { setAnimationOffsets(anim.name, 0, 0); } else { setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]); } } var animNames:Array<String> = this.boxSprite?.animation?.getNameList() ?? []; trace('[DIALOGUE BOX] Successfully loaded ${animNames.length} animations for ${id}'); boxSprite.animation.callback = this.onAnimationFrame; boxSprite.animation.finishCallback = this.onAnimationFinished; } /** * Called when an animation finishes. * @param name The name of the animation that just finished. */ function onAnimationFinished(name:String):Void {} /** * Called when the current animation's frame changes. * @param name The name of the current animation. * @param frameNumber The number of the current frame. * @param frameIndex The index of the current frame. * * For example, if an animation was defined as having the indexes [3, 0, 1, 2], * then the first callback would have frameNumber = 0 and frameIndex = 3. */ function onAnimationFrame(name:String = "", frameNumber:Int = -1, frameIndex:Int = -1):Void { // Do nothing by default. // This can be overridden by, for example, scripts, // or by calling `animationFrame.add()`. // Try not to do anything expensive here, it runs many times a second. } function loadText():Void { textDisplay = new FlxTypeText(0, 0, 300, '', 32); textDisplay.fieldWidth = _data.text.width; textDisplay.setFormat(_data.text.fontFamily, _data.text.size, FlxColor.fromString(_data.text.color), LEFT, SHADOW, FlxColor.fromString(_data.text.shadowColor ?? '#00000000'), false); textDisplay.borderSize = _data.text.shadowWidth ?? 2; // TODO: Add an option to configure this. textDisplay.sounds = [FunkinSound.load(Paths.sound('pixelText'), 0.6)]; textDisplay.completeCallback = onTypingComplete; textDisplay.x += _data.text.offsets[0]; textDisplay.y += _data.text.offsets[1]; add(textDisplay); } /** * @param name The name of the animation to play. * @param restart Whether to restart the animation if it is already playing. * @param reversed If true, play the animation backwards, from the last frame to the first. */ public function playAnimation(name:String, restart:Bool = false, reversed:Bool = false):Void { var correctName:String = correctAnimationName(name); if (correctName == null) return; this.boxSprite.animation.play(correctName, restart, false, 0); applyAnimationOffsets(correctName); } /** * Ensure that a given animation exists before playing it. * Will gracefully check for name, then name with stripped suffixes, then 'idle', then fail to play. * @param name */ function correctAnimationName(name:String):String { // If the animation exists, we're good. if (hasAnimation(name)) return name; trace('[DIALOGUE BOX] Animation "$name" does not exist!'); // Attempt to strip a `-alt` suffix, if it exists. if (name.lastIndexOf('-') != -1) { var correctName = name.substring(0, name.lastIndexOf('-')); trace('[DIALOGUE BOX] Attempting to fallback to "$correctName"'); return correctAnimationName(correctName); } else { if (name != 'idle') { trace('[DIALOGUE BOX] Attempting to fallback to "idle"'); return correctAnimationName('idle'); } else { trace('[DIALOGUE BOX] Failing animation playback.'); return null; } } } public function hasAnimation(id:String):Bool { if (this.boxSprite.animation == null) return false; return this.boxSprite.animation.getByName(id) != null; } /** * Returns the name of the animation that is currently playing. * If no animation is playing (usually this means the character is BROKEN!), * returns an empty string to prevent NPEs. */ public function getCurrentAnimation():String { if (this.animation == null || this.animation.curAnim == null) return ""; return this.animation.curAnim.name; } /** * Define the animation offsets for a specific animation. */ public function setAnimationOffsets(name:String, xOffset:Float, yOffset:Float):Void { animationOffsets.set(name, [xOffset, yOffset]); } /** * Retrieve an apply the animation offsets for a specific animation. */ function applyAnimationOffsets(name:String):Void { var offsets:Array<Float> = animationOffsets.get(name); if (offsets != null && !(offsets[0] == 0 && offsets[1] == 0)) { this.animOffsets = offsets; } else { this.animOffsets = [0, 0]; } } public function isAnimationFinished():Bool { return this.boxSprite?.animation?.finished ?? false; } public function onDialogueStart(event:DialogueScriptEvent):Void {} public function onDialogueCompleteLine(event:DialogueScriptEvent):Void {} public function onDialogueLine(event:DialogueScriptEvent):Void {} public function onDialogueSkip(event:DialogueScriptEvent):Void {} public function onDialogueEnd(event:DialogueScriptEvent):Void {} public function onUpdate(event:UpdateScriptEvent):Void {} public function onDestroy(event:ScriptEvent):Void { if (boxSprite != null) remove(boxSprite); boxSprite = null; if (textDisplay != null) remove(textDisplay); textDisplay = null; this.clear(); this.x = 0; this.y = 0; this.globalOffsets = [0, 0]; this.alpha = 0; this.kill(); } public function onScriptEvent(event:ScriptEvent):Void {} public override function toString():String { return 'DialogueBox($id)'; } static function _fetchData(id:String):Null<DialogueBoxData> { return DialogueBoxRegistry.instance.parseEntryDataWithMigration(id, DialogueBoxRegistry.instance.fetchEntryVersion(id)); } }