package funkin.play.character; import flixel.math.FlxPoint; import funkin.modding.events.ScriptEvent; import funkin.play.character.CharacterData.CharacterDataParser; import funkin.play.character.CharacterData.CharacterRenderType; import funkin.play.stage.Bopper; import funkin.play.notes.NoteDirection; /** * 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 BaseCharacter extends Bopper { // Metadata about a character. 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(default, set):CharacterType = OTHER; function set_characterType(value:CharacterType):CharacterType { return this.characterType = value; } /** * 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; /** * Set to true when the character dead. Part of the handling for death animations. */ public var isDead:Bool = false; /** * Set to true when the character being used in a special way. * This includes the Chart Editor and the Animation Editor. * * Used by scripts to ensure that they don't try to run code to interact with the stage when the stage doesn't actually exist. */ public var debug:Bool = false; /** * This character plays a given animation when hitting these specific combo numbers. */ public var comboNoteCounts(default, null):Array; /** * This character plays a given animation when dropping combos larger than these numbers. */ public var dropNoteCounts(default, null):Array; @:allow(funkin.ui.animDebugShit.DebugBoundingState) final _data:CharacterData; final singTimeSec:Float; /** * The offset between the corner of the sprite and the origin of the sprite (at the character's feet). * cornerPosition = stageData - characterOrigin */ public var characterOrigin(get, null):FlxPoint; function get_characterOrigin():FlxPoint { var xPos = (width / 2); // Horizontal center var yPos = (height); // Vertical bottom return new FlxPoint(xPos, yPos); } /** * The absolute position of the top-left of the character. * @return */ public var cornerPosition(get, set):FlxPoint; function get_cornerPosition():FlxPoint { return new FlxPoint(x, y); } function set_cornerPosition(value:FlxPoint):FlxPoint { var xDiff:Float = value.x - this.x; var yDiff:Float = value.y - this.y; this.cameraFocusPoint.x += xDiff; this.cameraFocusPoint.y += yDiff; super.set_x(value.x); super.set_y(value.y); return value; } /** * The absolute position of the character's feet, at the bottom-center of the sprite. */ public var feetPosition(get, null):FlxPoint; function get_feetPosition():FlxPoint { return new FlxPoint(x + characterOrigin.x, y + characterOrigin.y); } /** * Returns the point the camera should focus on. * Should be approximately centered on the character, and should not move based on the current animation. * * Set the position of this rather than reassigning it, so that anything referencing it will not be affected. */ public var cameraFocusPoint(default, null):FlxPoint = new FlxPoint(0, 0); override function set_animOffsets(value:Array):Array { if (animOffsets == null) value = [0, 0]; if ((animOffsets[0] == value[0]) && (animOffsets[1] == value[1])) return value; // Make sure animOffets are halved when scale is 0.5. var xDiff = (animOffsets[0] * this.scale.x / (this.isPixel ? 6 : 1)) - value[0]; var yDiff = (animOffsets[1] * this.scale.y / (this.isPixel ? 6 : 1)) - value[1]; // Call the super function so that camera focus point is not affected. super.set_x(this.x + xDiff); super.set_y(this.y + yDiff); return animOffsets = value; } /** * If the x position changes, other than via changing the animation offset, * then we need to update the camera focus point. */ override function set_x(value:Float):Float { if (value == this.x) return value; var xDiff = value - this.x; this.cameraFocusPoint.x += xDiff; return super.set_x(value); } /** * If the y position changes, other than via changing the animation offset, * then we need to update the camera focus point. */ override function set_y(value:Float):Float { if (value == this.y) return value; var yDiff = value - this.y; this.cameraFocusPoint.y += yDiff; return super.set_y(value); } public function new(id:String, renderType:CharacterRenderType) { super(); this.characterId = id; _data = CharacterDataParser.fetchCharacterData(this.characterId); if (_data == null) { throw 'Could not find character data for characterId: $characterId'; } else if (_data.renderType != renderType) { throw 'Render type mismatch for character ($characterId): expected ${renderType}, got ${_data.renderType}'; } else { this.characterName = _data.name; this.name = _data.name; this.singTimeSec = _data.singTime; this.globalOffsets = _data.offsets; this.flipX = _data.flipX; } shouldBop = false; } /** * Gets the value of flipX from the character data. * `!getFlipX()` is the direction Boyfriend should face. */ public function getDataFlipX():Bool { return _data.flipX; } function findCountAnimations(prefix:String):Array { var animNames:Array = this.animation.getNameList(); var result:Array = []; for (anim in animNames) { if (anim.startsWith(prefix)) { var comboNum:Null = Std.parseInt(anim.substring(prefix.length)); if (comboNum != null) { result.push(comboNum); } } } // Sort numerically. result.sort((a, b) -> a - b); return result; } /** * Reset the character so it can be used at the start of the level. * Call this when restarting the level. */ public function resetCharacter(resetCamera:Bool = true):Void { // Reset the animation offsets. This will modify x and y to be the absolute position of the character. this.animOffsets = [0, 0]; // Now we can set the x and y to be their original values without having to account for animOffsets. this.resetPosition(); // Make sure we are playing the idle animation (to reapply animOffsets)... this.dance(true); // Force to avoid the old animation playing with the wrong offset at the start of the song. // ...then update the hitbox so that this.width and this.height are correct. this.updateHitbox(); // Reset the camera focus point while we're at it. if (resetCamera) this.resetCameraFocusPoint(); } /** * Set the sprite scale to the appropriate value. * @param scale */ public function setScale(scale:Null):Void { if (scale == null) scale = 1.0; var feetPos:FlxPoint = feetPosition; this.scale.x = scale; this.scale.y = scale; this.updateHitbox(); // Reposition with newly scaled sprite. this.x = feetPos.x - characterOrigin.x + globalOffsets[0]; this.y = feetPos.y - characterOrigin.y + globalOffsets[1]; } /** * The per-character camera offset. */ var characterCameraOffsets(get, null):Array; function get_characterCameraOffsets():Array { return _data.cameraOffsets; } override function onCreate(event:ScriptEvent):Void { super.onCreate(event); // Make sure we are playing the idle animation... this.dance(true); // ...then update the hitbox so that this.width and this.height are correct. this.updateHitbox(); // Without the above code, width and height (and therefore character position) // will be based on the first animation in the sheet rather than the default animation. this.resetCameraFocusPoint(); // Child class should have created animations by now, // so we can query which ones are available. this.comboNoteCounts = findCountAnimations('combo'); // example: combo50 this.dropNoteCounts = findCountAnimations('drop'); // example: drop50 // trace('${this.animation.getNameList()}'); // trace('Combo note counts: ' + this.comboNoteCounts); // trace('Drop note counts: ' + this.dropNoteCounts); super.onCreate(event); } function resetCameraFocusPoint():Void { // Calculate the camera focus point var charCenterX = this.x + this.width / 2; var charCenterY = this.y + this.height / 2; this.cameraFocusPoint = new FlxPoint(charCenterX + _data.cameraOffsets[0], charCenterY + _data.cameraOffsets[1]); } public function initHealthIcon(isOpponent:Bool):Void { if (!isOpponent) { if (PlayState.instance.iconP1 == null) { trace('[WARN] Player 1 health icon not found!'); return; } PlayState.instance.iconP1.isPixel = _data.healthIcon?.isPixel ?? false; PlayState.instance.iconP1.characterId = _data.healthIcon.id; PlayState.instance.iconP1.size.set(_data.healthIcon.scale, _data.healthIcon.scale); PlayState.instance.iconP1.offset.x = _data.healthIcon.offsets[0]; PlayState.instance.iconP1.offset.y = _data.healthIcon.offsets[1]; PlayState.instance.iconP1.flipX = !_data.healthIcon.flipX; } else { if (PlayState.instance.iconP2 == null) { trace('[WARN] Player 2 health icon not found!'); return; } PlayState.instance.iconP2.isPixel = _data.healthIcon?.isPixel ?? false; PlayState.instance.iconP2.characterId = _data.healthIcon.id; PlayState.instance.iconP2.size.set(_data.healthIcon.scale, _data.healthIcon.scale); PlayState.instance.iconP2.offset.x = _data.healthIcon.offsets[0]; PlayState.instance.iconP2.offset.y = _data.healthIcon.offsets[1]; PlayState.instance.iconP2.flipX = _data.healthIcon.flipX; } } public override function onUpdate(event:UpdateScriptEvent):Void { super.onUpdate(event); // Reset hold timer for each note pressed. if (justPressedNote() && this.characterType == BF) { holdTimer = 0; } if (isDead) { playDeathAnimation(); return; } // If there is an animation, and another animation with the same name + "-hold" exists, // the second animation will play (and be looped if configured to do so) after the first animation finishes. // This is good for characters that need to hold a pose while maintaining an animation, like the parents (this keeps their eyes flickering) // and Darnell (this keeps the flame on his lighter flickering). // Works for idle, singLEFT/RIGHT/UP/DOWN, alt singing animations, and anything else really. if (!getCurrentAnimation().endsWith('-hold') && hasAnimation(getCurrentAnimation() + '-hold') && isAnimationFinished()) { playAnimation(getCurrentAnimation() + '-hold'); } // Handle character note hold time. if (isSinging()) { // TODO: Rework this code (and all character animations ugh) // such that the hold time is handled by padding frames, // and reverting to the idle animation is done when `isAnimationFinished()`. // This lets you add frames to the end of the sing animation to ease back into the idle! holdTimer += event.elapsed; var singTimeSec:Float = singTimeSec * (Conductor.beatLengthMs * 0.001); // x beats, to ms. if (getCurrentAnimation().endsWith('miss')) singTimeSec *= 2; // makes it feel more awkward when you miss // 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; FlxG.watch.addQuick('singTimeSec-${characterId}', singTimeSec); if (holdTimer > singTimeSec && shouldStopSinging) { // trace('holdTimer reached ${holdTimer}sec (> ${singTimeSec}), stopping sing animation'); holdTimer = 0; dance(true); } } else { holdTimer = 0; // super.onBeatHit handles the regular `dance()` calls. } FlxG.watch.addQuick('holdTimer-${characterId}', holdTimer); } /** * Since no `onBeatHit` or `dance` calls happen in GameOverSubState, * this regularly gets called instead. * * @param force Force the deathLoop animation to play, even if `firstDeath` is still playing. */ public function playDeathAnimation(force:Bool = false):Void { if (force || (getCurrentAnimation().startsWith('firstDeath') && isAnimationFinished())) { playAnimation('deathLoop' + GameOverSubState.animationSuffix); } } public function isSinging():Bool { return getCurrentAnimation().startsWith('sing'); } override function dance(force:Bool = false):Void { // Prevent default dancing behavior. if (isDead) return; if (!force) { if (isSinging()) return; if (['hey', 'cheer'].contains(getCurrentAnimation()) && !isAnimationFinished()) return; } // Prevent dancing while another animation is playing. if (!force && isSinging()) return; // Otherwise, fallback to the super dance() method, which handles playing the idle animation. super.dance(); } /** * Returns true if the player just pressed 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 justPressedNote(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_P, PlayerSettings.player1.controls.NOTE_DOWN_P, PlayerSettings.player1.controls.NOTE_UP_P, PlayerSettings.player1.controls.NOTE_RIGHT_P, ].contains(true); case 2: return [ PlayerSettings.player2.controls.NOTE_LEFT_P, PlayerSettings.player2.controls.NOTE_DOWN_P, PlayerSettings.player2.controls.NOTE_UP_P, PlayerSettings.player2.controls.NOTE_RIGHT_P, ].contains(true); } return false; } /** * 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.noteData.getMustHitNote() && characterType == BF) { // If the note is from the same strumline, play the sing animation. this.playSingAnimation(event.note.noteData.getDirection(), false); holdTimer = 0; } else if (!event.note.noteData.getMustHitNote() && characterType == DAD) { // If the note is from the same strumline, play the sing animation. this.playSingAnimation(event.note.noteData.getDirection(), false); holdTimer = 0; } } /** * Every time a note is missed, check if the note is from the same strumline. * If it is, then play the sing animation. */ public override function onNoteMiss(event:NoteScriptEvent) { super.onNoteMiss(event); if (event.note.noteData.getMustHitNote() && characterType == BF) { // If the note is from the same strumline, play the sing animation. this.playSingAnimation(event.note.noteData.getDirection(), true); } else if (!event.note.noteData.getMustHitNote() && characterType == DAD) { // If the note is from the same strumline, play the sing animation. this.playSingAnimation(event.note.noteData.getDirection(), true); } else if (event.note.noteData.getMustHitNote() && characterType == GF) { var dropAnim = ''; // Choose the combo drop anim to play. // If there are several (for example, drop10 and drop50) the highest one will be used. // If the combo count is too low, no animation will be played. for (count in dropNoteCounts) { if (event.comboCount >= count) { dropAnim = 'drop${count}'; } } if (dropAnim != '') { trace('Playing GF combo drop animation: ${dropAnim}'); this.playAnimation(dropAnim, true, true); } } } /** * Every time a wrong key is pressed, play the miss animation if we are Boyfriend. */ public override function onNoteGhostMiss(event:GhostMissNoteScriptEvent) { super.onNoteGhostMiss(event); if (event.eventCanceled || !event.playAnim) { // Skipping... return; } if (characterType == BF) { // If the note is from the same strumline, play the sing animation. // trace('Playing ghost miss animation...'); this.playSingAnimation(event.dir, true); } } 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`. */ public function playSingAnimation(dir:NoteDirection, ?miss:Bool = false, ?suffix:String = ''):Void { var anim:String = 'sing${dir.nameUpper}${miss ? 'miss' : ''}${suffix != '' ? '-${suffix}' : ''}'; // restart even if already playing, because the character might sing the same note twice. playAnimation(anim, true); } public override function playAnimation(name:String, restart:Bool = false, ?ignoreOther:Bool = false, ?reversed:Bool = false):Void { FlxG.watch.addQuick('playAnim(${characterName})', name); // trace('playAnim(${characterName}): ${name}'); super.playAnimation(name, restart, ignoreOther, reversed); } } /** * The type of a given character sprite. Defines its default behaviors. */ enum CharacterType { /** * The BF character has the following behaviors. * - At idle, dances with `danceLeft` and `danceRight` if available, or `idle` if not. * - When the player hits a note, plays the appropriate `singDIR` animation until BF is done singing. * - If there is a `singDIR-end` animation, the `singDIR` animation will play once before looping the `singDIR-end` animation until BF is done singing. * - If the player misses or hits a ghost note, plays the appropriate `singDIR-miss` animation until BF is done singing. */ BF; /** * The DAD character has the following behaviors. * - At idle, dances with `danceLeft` and `danceRight` if available, or `idle` if not. * - When the CPU hits a note, plays the appropriate `singDIR` animation until DAD is done singing. * - If there is a `singDIR-end` animation, the `singDIR` animation will play once before looping the `singDIR-end` animation until DAD is done singing. * - When the CPU misses a note (NOTE: This only happens via script, not by default), * plays the appropriate `singDIR-miss` animation until DAD is done singing. */ DAD; /** * The GF character has the following behaviors. * - At idle, dances with `danceLeft` and `danceRight` if available, or `idle` if not. * - If available, `combo###` animations will play when certain combo counts are reached. * - For example, `combo50` will play when the player hits 50 notes in a row. * - Multiple combo animations can be provided for different thresholds. * - If available, `drop###` animations will play when combos are dropped above certain thresholds. * - For example, `drop10` will play when the player drops a combo larger than 10. * - Multiple drop animations can be provided for different thresholds (i.e. dropping larger combos). * - No drop animation will play if one isn't applicable (i.e. if the combo count is too low). */ GF; /** * The OTHER character will only perform the `danceLeft`/`danceRight` or `idle` animation by default, depending on what's available. * Additional behaviors can be performed via scripts. */ OTHER; }