diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f4a865bf58f..60dc39e6e60 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -46,6 +46,7 @@ on: env: APP_VERSION: 4.0.0-${{ github.run_id }} + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: # ╔═══════════════════════════════════════════════════════════════════╗ @@ -155,7 +156,7 @@ jobs: steps: - uses: actions/checkout@v6.0.2 - - uses: krdlab/setup-haxe@v2.0.2 + - uses: sirthegamercoder/setup-haxe@master with: haxe-version: 4.3.7 @@ -193,7 +194,7 @@ jobs: steps: - uses: actions/checkout@v6.0.2 - - uses: krdlab/setup-haxe@v2.0.2 + - uses: sirthegamercoder/setup-haxe@master with: haxe-version: 4.3.7 @@ -244,7 +245,7 @@ jobs: steps: - uses: actions/checkout@v6.0.2 - - uses: krdlab/setup-haxe@v2.0.2 + - uses: sirthegamercoder/setup-haxe@master with: haxe-version: 4.3.7 @@ -280,7 +281,7 @@ jobs: steps: - uses: actions/checkout@v6.0.2 - - uses: krdlab/setup-haxe@v2.0.2 + - uses: sirthegamercoder/setup-haxe@master with: haxe-version: 4.3.7 @@ -329,7 +330,7 @@ jobs: steps: - uses: actions/checkout@v6.0.2 - - uses: krdlab/setup-haxe@v2.0.2 + - uses: sirthegamercoder/setup-haxe@master with: haxe-version: 4.3.7 @@ -370,7 +371,7 @@ jobs: steps: - uses: actions/checkout@v6.0.2 - - uses: krdlab/setup-haxe@v2.0.2 + - uses: sirthegamercoder/setup-haxe@master with: haxe-version: 4.3.7 @@ -383,14 +384,14 @@ jobs: - name: Setup Android SDK uses: amyu/setup-android@v5.5 with: - sdk-version: 35 - build-tools-version: 35.0.0 + sdk-version: 36 + build-tools-version: 36.0.0 ndk-version: 27.3.13750724 generate-job-summary: false - name: Install SDK components run: | - $ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_SDK_ROOT "platforms;android-35" "build-tools;35.0.0" "ndk;27.3.13750724" "cmdline-tools;latest" + $ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_SDK_ROOT "platforms;android-36" "build-tools;36.0.0" "ndk;27.3.13750724" "cmdline-tools;latest" - name: Install Haxelib run: | @@ -443,7 +444,7 @@ jobs: steps: - uses: actions/checkout@v6.0.2 - - uses: krdlab/setup-haxe@v2.0.2 + - uses: sirthegamercoder/setup-haxe@master with: haxe-version: 4.3.7 diff --git a/Project.xml b/Project.xml index a0247a2cbce..d961f912155 100644 --- a/Project.xml +++ b/Project.xml @@ -2,7 +2,7 @@ - + @@ -191,6 +191,8 @@ + +
diff --git a/README.es-LA.md b/README.es-LA.md index 8ba35ae0408..9340ea217da 100644 --- a/README.es-LA.md +++ b/README.es-LA.md @@ -117,9 +117,9 @@ relájate — el progreso no espera. Necesitas tener: - Android Build Tools / Android Command Line Tools -- Android SDK 35 +- Android SDK 36 - Android NDK r27d -- Java JDK 17 +- Java JDK 21 # Características después de 1.0.4 diff --git a/README.id-ID.md b/README.id-ID.md index 8bb916bf295..e795c861a54 100644 --- a/README.id-ID.md +++ b/README.id-ID.md @@ -117,9 +117,9 @@ tenang — kemajuan tidak menunggu. Anda memerlukan: - Android Build Tools / Android Command Line Tools -- Android SDK 35 +- Android SDK 36 - Android NDK r27d -- Java JDK 17 +- Java JDK 21 # Fitur-fitur setelah 1.0.4 diff --git a/README.md b/README.md index 1103822226f..cd8c149d45d 100644 --- a/README.md +++ b/README.md @@ -117,9 +117,9 @@ relax — progress doesn’t wait. You need to have: - Android Build Tools / Android Command Line Tools -- Android SDK 35 +- Android SDK 36 - Android NDK r27d -- Java JDK 17 +- Java JDK 21 # Features after 1.0.4 diff --git a/assets/base_game/shared/data/spaghetti/metadata.json b/assets/base_game/shared/data/spaghetti/metadata.json new file mode 100644 index 00000000000..525068f48a6 --- /dev/null +++ b/assets/base_game/shared/data/spaghetti/metadata.json @@ -0,0 +1,3 @@ +{ + "albumId": "spaghetti" +} \ No newline at end of file diff --git a/assets/base_game/shared/images/albumRoll/spaghetti.png b/assets/base_game/shared/images/albumRoll/spaghetti.png new file mode 100644 index 00000000000..41ebb5ca342 Binary files /dev/null and b/assets/base_game/shared/images/albumRoll/spaghetti.png differ diff --git a/assets/base_game/shared/images/albumRoll/volume1.png b/assets/base_game/shared/images/albumRoll/volume1.png new file mode 100644 index 00000000000..db5d89de5f3 Binary files /dev/null and b/assets/base_game/shared/images/albumRoll/volume1.png differ diff --git a/assets/base_game/shared/images/albumRoll/volume2-alt.png b/assets/base_game/shared/images/albumRoll/volume2-alt.png new file mode 100644 index 00000000000..3fdfa4623ca Binary files /dev/null and b/assets/base_game/shared/images/albumRoll/volume2-alt.png differ diff --git a/assets/base_game/shared/images/albumRoll/volume2.png b/assets/base_game/shared/images/albumRoll/volume2.png new file mode 100644 index 00000000000..5c0cc796bb0 Binary files /dev/null and b/assets/base_game/shared/images/albumRoll/volume2.png differ diff --git a/assets/base_game/shared/images/albumRoll/volume3.png b/assets/base_game/shared/images/albumRoll/volume3.png new file mode 100644 index 00000000000..710ce8adb4c Binary files /dev/null and b/assets/base_game/shared/images/albumRoll/volume3.png differ diff --git a/assets/shared/sounds/FadeTransition.mp3 b/assets/shared/sounds/FadeTransition.mp3 new file mode 100644 index 00000000000..a63dcb84a00 Binary files /dev/null and b/assets/shared/sounds/FadeTransition.mp3 differ diff --git a/assets/shared/sounds/FadeTransition.ogg b/assets/shared/sounds/FadeTransition.ogg new file mode 100644 index 00000000000..4be0ef315ec Binary files /dev/null and b/assets/shared/sounds/FadeTransition.ogg differ diff --git a/gitChangelog.txt b/gitChangelog.txt index 609a849094f..8396841f149 100644 --- a/gitChangelog.txt +++ b/gitChangelog.txt @@ -1,7 +1,6 @@ -- SScript is dead; many checks have been added to HScript for mods in both 0.7.3 and 1.0.4 -- Fixed a bug that prevented the Alphabet from loading on mobile -- Fixed a crash on mobile when starting the game due to incorrect assets (uninstalling and reinstalling is required) -- Fixed a bug in the Character Editor that caused a crash when importing a Texture Atlas -- Re-added controls in freeplay mode to allow normal navigation; previously, the list could not be moved -- Some hardcoded events have been removed -- Improved Hibox Arrows on mobile \ No newline at end of file +- New user interface for FreeplayState . +- Fixed storage errors on Android that caused unexpected game crashes and prevented files from being copied to their correct folders. +- Created a native file manager for Android users, allowing them to access game files without connecting their device to a PC. +- Added a new native dropdown menu for Android users, replacing the PsychUIDropdownMenu logic, which was somewhat difficult to manage on Android. +- Added several older options and more. +- Please report any bugs. Until next time. \ No newline at end of file diff --git a/gitVersion.txt b/gitVersion.txt index 3c43790f5d8..c04c650a7ad 100644 --- a/gitVersion.txt +++ b/gitVersion.txt @@ -1 +1 @@ -1.2.6 +1.2.7 diff --git a/source/funkin/Preferences.hx b/source/funkin/Preferences.hx index 0a65008eb65..e7826dff3de 100644 --- a/source/funkin/Preferences.hx +++ b/source/funkin/Preferences.hx @@ -43,6 +43,7 @@ import funkin.ui.title.TitleState; public var middleScroll:Bool = false; public var opponentStrums:Bool = true; public var showFPS:Bool = true; + public var vsync:Bool = false; public var fpsDebugLevel:Int = 0; // FPSCounter debug level (persistent) public var showWatermark:Bool = false; public var flashing:Bool = true; @@ -65,13 +66,16 @@ import funkin.ui.title.TitleState; public var hideSustainSplash:Bool = false; public var showKeyViewer:Bool = false; public var iconBounceType:String = 'Default'; - public var judgementCounter:Bool = true; + public var judgementCounter:Bool = false; public var showCombo:Bool = true; public var comboInGame:Bool = false; public var useFreakyFont:Bool = false; public var showStateInFPS:Bool = true; public var showEndCountdown:Bool = false; // Enables/disables the end countdown public var endCountdownSeconds:Int = 10; // End countdown seconds (10-30) + #if android + public var useNativeWavyTimebar:Bool = false; + #end // ========== Modchart Config Options ========== // Camera & 3D Settings @@ -112,9 +116,12 @@ import funkin.ui.title.TitleState; public var timeBarType:String = 'Time Left'; public var shadedTimeBar:Bool = false; public var scoreZoom:Bool = true; + public var timeBump:Bool = false; public var noReset:Bool = false; public var healthBarAlpha:Float = 1; public var smoothHealthBar:Bool = true; + public var smoothHPBug:Bool = false; + public var usePsychScoreText:Bool = true; public var hitsoundVolume:Float = 0; public var hitSounds:String = "None"; public var hitsoundType:String = "None"; @@ -141,6 +148,7 @@ import funkin.ui.title.TitleState; 'instakill' => false, 'practice' => false, 'botplay' => false, + 'opponentdrain' => false, // JS Engine-style: opponent note hits drain player health 'opponentplay' => false, 'perfect' => false, // Perfect Mode - insta-kill on any judgement below Sick 'nodroppenalty' => false // Hold drops don't cause misses @@ -160,6 +168,7 @@ import funkin.ui.title.TitleState; public var loadingScreen:Bool = true; public var language:String = 'en-US'; public var abbreviateScore:Bool = true; + public var newfreeplay:Bool = true; public var heavyCharts:Bool = false; // Heavy Charts Mode for heavy charts public var vanillaTransition:Bool = false; // Use vanilla Psych Engine transition instead of custom @@ -346,6 +355,18 @@ class Preferences { FlxG.drawFramerate = data.framerate; } + #if (!html5 && !switch) + try + { + if (FlxG.stage != null && FlxG.stage.application != null && FlxG.stage.application.window != null) + Reflect.setProperty(FlxG.stage.application.window, 'vsync', data.vsync); + } + catch (e:Dynamic) + { + // Some targets may not expose window vsync. + } + #end + if(FlxG.save.data.gameplaySettings != null) { var savedMap:Map = FlxG.save.data.gameplaySettings; diff --git a/source/funkin/data/song/Song.hx b/source/funkin/data/song/Song.hx index afe35ac75bb..4ae9adf1ed9 100644 --- a/source/funkin/data/song/Song.hx +++ b/source/funkin/data/song/Song.hx @@ -5,12 +5,16 @@ import lime.utils.Assets; import funkin.play.notes.Note; import funkin.data.stage.StageData; +import funkin.ui.debug.charting.components.PsychJsonPrinter; typedef SwagSong = { var song:String; var notes:Array; var events:Array; + @:optional var notesV2:Array; + @:optional var eventsV2:Array; + @:optional var bpmChangesV2:Array; var bpm:Float; var needsVoices:Bool; var speed:Float; @@ -32,6 +36,7 @@ typedef SwagSong = @:optional var arrowSkin:String; @:optional var splashSkin:String; @:optional var isAnimated:Bool; // Soporte para íconos animados en el chart + @:optional var useModcharts:Bool; // If true, the modchart manager is activated automatically without needing onInitModchart in scripts } typedef SwagSection = @@ -45,6 +50,66 @@ typedef SwagSection = @:optional var changeBPM:Bool; } +// ── psych_v2 typedefs ──────────────────────────────────────────────────────── + +/** Flat note entry used in the psych_v2 format. */ +typedef SongNoteV2 = +{ + var t:Float; // strumTime (ms) + var d:Int; // 0-3 = player, 4-7 = opponent (absolute, no mustHitSection) + var l:Float; // sustainLength in ms (0 = tap note) + @:optional var type:String; // note type string; omit for default +} + +/** Flat event entry used in the psych_v2 format. */ +typedef SongEventV2 = +{ + var t:Float; // time in ms + var name:String; // event name + var v:Dynamic; // arbitrary value payload +} + +/** BPM change entry used in the psych_v2 format. */ +typedef BpmChangeV2 = +{ + var time:Float; // time in ms at which the new BPM takes effect + var bpm:Float; +} + +/** Full psych_v2 chart structure (serialization format; runtime uses SwagSong). */ +typedef SwagSongV2 = +{ + var format:String; // always "psych_v2" + var song:String; + var bpm:Float; + var speed:Float; + var needsVoices:Bool; + var offset:Float; + var stage:String; + var characters:SongCharactersV2; + var bpmChanges:Array; + var notes:Array; + var events:Array; + @:optional var arrowSkin:String; + @:optional var splashSkin:String; + @:optional var disableNoteRGB:Bool; + @:optional var gameOverChar:String; + @:optional var gameOverSound:String; + @:optional var gameOverLoop:String; + @:optional var gameOverEnd:String; + @:optional var useModcharts:Bool; +} + +/** Character names used in the psych_v2 format. */ +typedef SongCharactersV2 = +{ + var player:String; + var opponent:String; + var girlfriend:String; +} + +// ──────────────────────────────────────────────────────────────────────────── + class Song { public var song:String; @@ -121,6 +186,383 @@ class Song } } + /** + * Converts a runtime psych_v1 SwagSong to the psych_v2 serialization format. + * The returned object is meant to be JSON-serialized and saved to disk. + * - Sections are eliminated; notes become a flat sorted array. + * - mustHitSection / changeBPM are extracted into events and bpmChanges. + */ + public static function upgradeToV2(song:SwagSong):SwagSongV2 + { + var bpmChanges:Array = [{time: 0.0, bpm: song.bpm}]; + var curBpm:Float = song.bpm; + var curTime:Float = 0.0; + + var flatNotes:Array = []; + var cameraEvents:Array = []; + var lastMustHit:Null = null; + + if (song.notes != null) + { + for (section in song.notes) + { + if (section == null) continue; + var beatsRaw:Null = cast section.sectionBeats; + var beats:Float = (beatsRaw != null && !Math.isNaN(beatsRaw)) ? beatsRaw : 4.0; + + // Emit a Camera Focus event whenever mustHitSection changes + if (lastMustHit == null || lastMustHit != section.mustHitSection) + { + cameraEvents.push({ + t: curTime, + name: 'Camera Focus', + v: { target: section.mustHitSection ? 'player' : 'opponent' } + }); + lastMustHit = section.mustHitSection; + } + + // BPM change: record new entry in bpmChanges (avoid duplicate at t=0) + if (section.changeBPM == true && section.bpm != null && section.bpm != curBpm) + { + curBpm = section.bpm; + if (curTime > 0) + bpmChanges.push({ time: curTime, bpm: curBpm }); + else + bpmChanges[0] = { time: 0.0, bpm: curBpm }; + } + + // Flatten notes + if (section.sectionNotes != null) + { + for (note in section.sectionNotes) + { + if (note == null) continue; + var noteObj:SongNoteV2 = { t: note[0], d: note[1], l: note[2] != null ? note[2] : 0.0 }; + var noteType:Dynamic = note[3]; + if (noteType != null && Std.isOfType(noteType, String) && (noteType:String).length > 0) + noteObj.type = noteType; + flatNotes.push(noteObj); + } + } + + curTime += (60000.0 / curBpm) * beats; + } + } + + // Convert v1 events [[time, [[name, val1, val2], ...]], ...] to flat SongEventV2 array + var flatEvents:Array = []; + if (song.events != null) + { + for (ev in song.events) + { + if (ev == null) continue; + var subEvents:Array = cast ev[1]; + if (subEvents == null) continue; + for (sub in subEvents) + flatEvents.push({ t: ev[0], name: sub[0], v: { val1: sub[1], val2: sub[2] } }); + } + } + // Merge camera events with song events and sort by time + for (ce in cameraEvents) flatEvents.push(ce); + flatEvents.sort(function(a, b) return Std.int(a.t - b.t)); + flatNotes.sort(function(a, b) return Std.int(a.t - b.t)); + + var v2:SwagSongV2 = { + format: 'psych_v2', + song: song.song, + bpm: song.bpm, + speed: song.speed, + needsVoices: song.needsVoices, + offset: song.offset, + stage: song.stage != null ? song.stage : 'stage', + characters: { + player: song.player1 != null ? song.player1 : 'bf', + opponent: song.player2 != null ? song.player2 : 'dad', + girlfriend: song.gfVersion != null ? song.gfVersion : 'gf' + }, + bpmChanges: bpmChanges, + notes: flatNotes, + events: flatEvents + }; + + if (song.arrowSkin != null) v2.arrowSkin = song.arrowSkin; + if (song.splashSkin != null) v2.splashSkin = song.splashSkin; + if (song.disableNoteRGB == true) v2.disableNoteRGB = true; + if (song.gameOverChar != null) v2.gameOverChar = song.gameOverChar; + if (song.gameOverSound != null) v2.gameOverSound = song.gameOverSound; + if (song.gameOverLoop != null) v2.gameOverLoop = song.gameOverLoop; + if (song.gameOverEnd != null) v2.gameOverEnd = song.gameOverEnd; + if (song.useModcharts == true) v2.useModcharts = true; + + return v2; + } + + /** + * Converts a psych_v2 JSON object back to a runtime SwagSong with sections. + * Called automatically by parseJSON when it detects format = "psych_v2". + */ + public static function downgradeFromV2(v2:Dynamic):SwagSong + { + var rawChanges:Array = v2.bpmChanges != null ? cast v2.bpmChanges : []; + var baseBpm:Float = v2.bpm != null ? v2.bpm : 100.0; + var bpmChanges:Array = rawChanges.copy(); + bpmChanges.sort(function(a, b) return Std.int(a.time - b.time)); + if (bpmChanges.length == 0 || bpmChanges[0].time > 0) + bpmChanges.unshift({ time: 0.0, bpm: baseBpm }); + + // Returns the active BPM at time t + var getBpmAt = function(t:Float):Float + { + var bpm:Float = baseBpm; + for (change in bpmChanges) + { + if (change.time <= t + 1) bpm = change.bpm; + else break; + } + return bpm; + }; + + var flatNotes:Array = v2.notes != null ? cast v2.notes : []; + var flatEvents:Array = v2.events != null ? cast v2.events : []; + + // Find the time of the last note + var lastTime:Float = 0; + for (note in flatNotes) + { + var end:Float = note.t + (note.l != null && note.l > 0 ? note.l : 0.0); + if (end > lastTime) lastTime = end; + } + if (lastTime <= 0) lastTime = (60000.0 / baseBpm) * 4; + + // Build section start times (4 beats per section in v2) + var sectionTimes:Array = []; + var t:Float = 0; + while (t <= lastTime + 1) + { + sectionTimes.push(t); + t += (60000.0 / getBpmAt(t)) * 4; + } + + // Separate Camera Focus events to reconstruct mustHitSection + var cameraEvents:Array = flatEvents.filter(function(e) return e.name == 'Camera Focus'); + var otherEvents:Array = flatEvents.filter(function(e) return e.name != 'Camera Focus'); + cameraEvents.sort(function(a, b) return Std.int(a.t - b.t)); + + var sectionMustHits:Array = []; + var camIdx:Int = 0; + var lastMustHit:Bool = false; + for (i in 0...sectionTimes.length) + { + var secStart:Float = sectionTimes[i]; + var secEnd:Float = (i + 1 < sectionTimes.length) ? sectionTimes[i + 1] : Math.POSITIVE_INFINITY; + while (camIdx < cameraEvents.length && cameraEvents[camIdx].t < secEnd) + { + var cam:Dynamic = cameraEvents[camIdx++]; + if (cam.t >= secStart) + lastMustHit = Std.string(cam.v.target) == 'player'; + } + sectionMustHits.push(lastMustHit); + } + + // Build sections + var sections:Array = []; + var lastBpm:Float = baseBpm; + for (i in 0...sectionTimes.length) + { + var bpm:Float = getBpmAt(sectionTimes[i]); + var sec:SwagSection = { + sectionNotes: [], + sectionBeats: 4.0, + mustHitSection: sectionMustHits[i] + }; + if (bpm != lastBpm) + { + sec.changeBPM = true; + sec.bpm = bpm; + lastBpm = bpm; + } + sections.push(sec); + } + + // Distribute flat notes into the correct section + for (note in flatNotes) + { + var secIdx:Int = sectionTimes.length - 1; + for (i in 0...sectionTimes.length - 1) + { + if (sectionTimes[i + 1] > note.t) { secIdx = i; break; } + } + if (secIdx >= 0 && secIdx < sections.length) + { + var noteArr:Array = [note.t, note.d, note.l != null ? note.l : 0.0]; + var noteType:Dynamic = note.type; + if (noteType != null && Std.string(noteType).length > 0) + noteArr.push(Std.string(noteType)); + sections[secIdx].sectionNotes.push(noteArr); + } + } + + // Rebuild v1 events from other events: group by time → [[time, [[name,v1,v2], ...]], ...] + var evGroups:Map>> = []; + var evTimes:Array = []; + for (ev in otherEvents) + { + var key:String = Std.string(ev.t); + var val1:String = (ev.v != null && ev.v.val1 != null) ? Std.string(ev.v.val1) : ''; + var val2:String = (ev.v != null && ev.v.val2 != null) ? Std.string(ev.v.val2) : ''; + if (!evGroups.exists(key)) { evGroups.set(key, []); evTimes.push(ev.t); } + evGroups.get(key).push([ev.name, val1, val2]); + } + evTimes.sort(function(a, b) return Std.int(a - b)); + var builtEvents:Array = []; + for (et in evTimes) builtEvents.push([et, evGroups.get(Std.string(et))]); + + var chars:Dynamic = v2.characters != null ? v2.characters : {}; + var song:SwagSong = { + song: v2.song, + notes: sections, + events: builtEvents, + bpm: baseBpm, + needsVoices: v2.needsVoices != null ? v2.needsVoices : true, + speed: v2.speed != null ? v2.speed : 1.0, + offset: v2.offset != null ? v2.offset : 0.0, + player1: chars.player != null ? chars.player : 'bf', + player2: chars.opponent != null ? chars.opponent : 'dad', + gfVersion: chars.girlfriend != null ? chars.girlfriend : 'gf', + stage: v2.stage != null ? v2.stage : 'stage', + format: 'psych_v2' + }; + + if (v2.arrowSkin != null) song.arrowSkin = v2.arrowSkin; + if (v2.splashSkin != null) song.splashSkin = v2.splashSkin; + if (v2.disableNoteRGB == true) song.disableNoteRGB = true; + if (v2.gameOverChar != null) song.gameOverChar = v2.gameOverChar; + if (v2.gameOverSound != null) song.gameOverSound = v2.gameOverSound; + if (v2.gameOverLoop != null) song.gameOverLoop = v2.gameOverLoop; + if (v2.gameOverEnd != null) song.gameOverEnd = v2.gameOverEnd; + if (v2.useModcharts == true) song.useModcharts = true; + + return song; + } + + private static function buildRuntimeSectionsFromV2(v2:Dynamic):Array + { + var rawChanges:Array = v2.bpmChanges != null ? cast v2.bpmChanges : []; + var baseBpm:Float = v2.bpm != null ? v2.bpm : 100.0; + var bpmChanges:Array = rawChanges.copy(); + bpmChanges.sort(function(a, b) return Std.int(a.time - b.time)); + if (bpmChanges.length == 0 || bpmChanges[0].time > 0) + bpmChanges.unshift({ time: 0.0, bpm: baseBpm }); + + var getBpmAt = function(t:Float):Float + { + var bpm:Float = baseBpm; + for (change in bpmChanges) + { + if (change.time <= t + 1) bpm = change.bpm; + else break; + } + return bpm; + }; + + var flatNotes:Array = v2.notes != null ? cast v2.notes : []; + var flatEvents:Array = v2.events != null ? cast v2.events : []; + + var lastTime:Float = 0; + for (note in flatNotes) + { + var end:Float = note.t + (note.l != null && note.l > 0 ? note.l : 0.0); + if (end > lastTime) lastTime = end; + } + for (ev in flatEvents) + { + if (ev != null && ev.t != null && ev.t > lastTime) + lastTime = ev.t; + } + for (change in bpmChanges) + { + if (change != null && change.time != null && change.time > lastTime) + lastTime = change.time; + } + if (lastTime <= 0) lastTime = (60000.0 / baseBpm) * 4; + + var sectionTimes:Array = []; + var t:Float = 0; + while (t <= lastTime + 1) + { + sectionTimes.push(t); + t += (60000.0 / getBpmAt(t)) * 4; + } + + var cameraEvents:Array = []; + for (ev in flatEvents) + { + if (ev != null && ev.name == 'Camera Focus') + cameraEvents.push(ev); + } + cameraEvents.sort(function(a, b) return Std.int(a.t - b.t)); + + var sectionMustHits:Array = []; + var camIdx:Int = 0; + var lastMustHit:Bool = false; + for (i in 0...sectionTimes.length) + { + var secStart:Float = sectionTimes[i]; + var secEnd:Float = (i + 1 < sectionTimes.length) ? sectionTimes[i + 1] : Math.POSITIVE_INFINITY; + while (camIdx < cameraEvents.length && cameraEvents[camIdx].t < secEnd) + { + var cam:Dynamic = cameraEvents[camIdx++]; + if (cam.t >= secStart) + { + var payload:Dynamic = Reflect.field(cam, 'v'); + if (payload != null && Reflect.hasField(payload, 'target')) + lastMustHit = Std.string(Reflect.field(payload, 'target')) == 'player'; + } + } + sectionMustHits.push(lastMustHit); + } + + var sections:Array = []; + var lastBpm:Float = baseBpm; + for (i in 0...sectionTimes.length) + { + var bpm:Float = getBpmAt(sectionTimes[i]); + var sec:SwagSection = { + sectionNotes: [], + sectionBeats: 4.0, + mustHitSection: sectionMustHits[i] + }; + if (bpm != lastBpm) + { + sec.changeBPM = true; + sec.bpm = bpm; + lastBpm = bpm; + } + sections.push(sec); + } + + return sections; + } + + private static function prepareRuntimeFromV2(songJson:SwagSong):SwagSong + { + var chars:Dynamic = Reflect.field(songJson, 'characters'); + if (chars != null) + { + songJson.player1 = Reflect.hasField(chars, 'player') ? Reflect.field(chars, 'player') : songJson.player1; + songJson.player2 = Reflect.hasField(chars, 'opponent') ? Reflect.field(chars, 'opponent') : songJson.player2; + songJson.gfVersion = Reflect.hasField(chars, 'girlfriend') ? Reflect.field(chars, 'girlfriend') : songJson.gfVersion; + } + + songJson.notesV2 = cast (Reflect.hasField(songJson, 'notes') ? Reflect.field(songJson, 'notes') : []); + songJson.eventsV2 = cast (Reflect.hasField(songJson, 'events') ? Reflect.field(songJson, 'events') : []); + songJson.bpmChangesV2 = cast (Reflect.hasField(songJson, 'bpmChanges') ? Reflect.field(songJson, 'bpmChanges') : []); + + songJson.notes = buildRuntimeSectionsFromV2(songJson); + songJson.events = []; + return songJson; + } + public static var chartPath:String; public static var loadedSongName:String; public static function loadFromJson(jsonInput:String, ?folder:String):SwagSong @@ -271,7 +713,26 @@ class Song rawData = Assets.getText(_lastPath); } - return rawData != null ? parseJSON(rawData, jsonInput) : null; + if (rawData == null) return null; + var song:SwagSong = parseJSON(rawData, jsonInput); + + // Auto-migrate charts that are not yet in psych_v2 format. + // Only runs for mod files (writable paths on disk), never for embedded assets. + #if (MODS_ALLOWED && sys) + if (song != null && _lastPath != null && sys.FileSystem.exists(_lastPath) + && (song.format == null || !song.format.startsWith('psych_v2'))) + { + try + { + var v2:Dynamic = upgradeToV2(song); + sys.io.File.saveContent(_lastPath, PsychJsonPrinter.print(v2, ['notes', 'events', 'bpmChanges', 'characters'])); + trace('Auto-migrated chart "$jsonInput" to psych_v2 at $_lastPath'); + } + catch (e:Dynamic) { trace('Could not auto-migrate chart "$jsonInput": $e'); } + } + #end + + return song; } public static function parseJSON(rawData:String, ?nameForError:String = null, ?convertTo:String = 'psych_v1'):SwagSong @@ -294,9 +755,17 @@ class Song case 'psych_v1': if(!fmt.startsWith('psych_v1')) //Convert to Psych 1.0 format { - trace('converting chart $nameForError with format $fmt to psych_v1 format...'); - songJson.format = 'psych_v1_convert'; - convert(songJson); + if (fmt == 'psych_v2') + { + trace('loading v2 chart $nameForError, using native runtime v2 path...'); + songJson = prepareRuntimeFromV2(songJson); + } + else + { + trace('converting chart $nameForError with format $fmt to psych_v1 format...'); + songJson.format = 'psych_v1_convert'; + convert(songJson); + } } } } diff --git a/source/funkin/mobile/backend/TouchScroll.hx b/source/funkin/mobile/backend/TouchScroll.hx index 75b7336269e..c5a55237be2 100644 --- a/source/funkin/mobile/backend/TouchScroll.hx +++ b/source/funkin/mobile/backend/TouchScroll.hx @@ -16,6 +16,10 @@ class TouchScroll public static inline var TAP_DISTANCE_THRESHOLD:Float = 15.0; // Pixels - if movement < this, it's a tap public static inline var TAP_TIME_THRESHOLD:Float = 0.3; // Seconds - max duration for a tap public static inline var SWIPE_VELOCITY_THRESHOLD:Float = 50.0; // Pixels/second - minimum for swipe + public static inline var AXIS_LOCK_RATIO:Float = 1.2; // Primary axis must exceed secondary axis by this ratio + public static inline var MAX_VELOCITY:Float = 4200.0; // Clamp velocity to avoid huge spikes + public static inline var VELOCITY_SMOOTHING:Float = 0.35; // Blend factor for stable velocity + public static inline var MIN_DELTA:Float = 0.05; // Ignore tiny jitter deltas // Momentum/inertia settings public static inline var MOMENTUM_FRICTION:Float = 0.92; // Deceleration multiplier (0-1) @@ -27,6 +31,7 @@ class TouchScroll private var touchStartTime:Float; private var lastTouchPos:FlxPoint; private var lastMoveTime:Float; + private var justReleasedScroll:Bool = false; // State public var isScrolling(default, null):Bool = false; @@ -54,6 +59,7 @@ class TouchScroll isTap = false; scrollVelocity = 0; totalDelta = 0; + justReleasedScroll = false; activeTouch = null; touchStartTime = 0; lastMoveTime = 0; @@ -66,14 +72,31 @@ class TouchScroll public function update():Float { var currentTouch:FlxTouch = null; + justReleasedScroll = false; - // Find active touch - for (touch in FlxG.touches.list) + // Prioritize the currently active touch. + if (activeTouch != null) { - if (touch != null && (touch.justPressed || touch.pressed)) + if (activeTouch.pressed || activeTouch.justReleased) { - currentTouch = touch; - break; + currentTouch = activeTouch; + } + else + { + activeTouch = null; + } + } + + // Fallback: find a new touch. + if (currentTouch == null) + { + for (touch in FlxG.touches.list) + { + if (touch != null && (touch.justPressed || touch.pressed)) + { + currentTouch = touch; + break; + } } } @@ -83,7 +106,7 @@ class TouchScroll touchStartPos.set(currentTouch.screenX, currentTouch.screenY); touchCurrentPos.set(currentTouch.screenX, currentTouch.screenY); lastTouchPos.set(currentTouch.screenX, currentTouch.screenY); - touchStartTime = FlxG.game.ticks / 1000.0; + touchStartTime = haxe.Timer.stamp(); lastMoveTime = touchStartTime; activeTouch = currentTouch; isScrolling = false; @@ -96,43 +119,52 @@ class TouchScroll { touchCurrentPos.set(currentTouch.screenX, currentTouch.screenY); - var distance = getDistance(); - var duration = (FlxG.game.ticks / 1000.0) - touchStartTime; + var primaryDistance = getDistance(); + var secondaryDistance = getSecondaryDistance(); // Determine if this is a scroll or still could be a tap - if (distance > TAP_DISTANCE_THRESHOLD) + if (!isScrolling && primaryDistance > TAP_DISTANCE_THRESHOLD && primaryDistance > secondaryDistance * AXIS_LOCK_RATIO) { isScrolling = true; isTap = false; - + } + + if (isScrolling) + { // Calculate velocity for momentum - var currentTime = FlxG.game.ticks / 1000.0; + var currentTime = haxe.Timer.stamp(); var deltaTime = currentTime - lastMoveTime; if (deltaTime > 0) { - var delta = vertical ? + var delta = vertical ? (touchCurrentPos.y - lastTouchPos.y) : (touchCurrentPos.x - lastTouchPos.x); - - scrollVelocity = delta / deltaTime; + + var instantVelocity = FlxMath.bound(delta / deltaTime, -MAX_VELOCITY, MAX_VELOCITY); + scrollVelocity = (scrollVelocity * (1 - VELOCITY_SMOOTHING)) + (instantVelocity * VELOCITY_SMOOTHING); totalDelta += delta; lastTouchPos.set(touchCurrentPos.x, touchCurrentPos.y); lastMoveTime = currentTime; - - return delta; + + if (Math.abs(delta) >= MIN_DELTA) + { + return delta; + } } } } // Touch ended else if (activeTouch != null && activeTouch.justReleased) { - var distance = getDistance(); - var duration = (FlxG.game.ticks / 1000.0) - touchStartTime; + var primaryDistance = getDistance(); + var secondaryDistance = getSecondaryDistance(); + var duration = haxe.Timer.stamp() - touchStartTime; + var totalDistance = Math.sqrt((primaryDistance * primaryDistance) + (secondaryDistance * secondaryDistance)); // Determine if it was a tap - if (distance < TAP_DISTANCE_THRESHOLD && duration < TAP_TIME_THRESHOLD) + if (!isScrolling && totalDistance < TAP_DISTANCE_THRESHOLD && duration < TAP_TIME_THRESHOLD) { isTap = true; isScrolling = false; @@ -140,9 +172,16 @@ class TouchScroll } else { - isScrolling = false; isTap = false; - // Keep velocity for momentum + if (Math.abs(scrollVelocity) < SWIPE_VELOCITY_THRESHOLD) + { + scrollVelocity = 0; + } + if (isScrolling) + { + justReleasedScroll = true; + } + isScrolling = false; } activeTouch = null; @@ -150,7 +189,8 @@ class TouchScroll // No touch - apply momentum if scrolling ended else if (activeTouch == null && Math.abs(scrollVelocity) > MOMENTUM_MIN_VELOCITY) { - scrollVelocity *= MOMENTUM_FRICTION; + var frameAdjustedFriction = Math.pow(MOMENTUM_FRICTION, FlxG.elapsed * 60); + scrollVelocity *= frameAdjustedFriction; if (Math.abs(scrollVelocity) < MOMENTUM_MIN_VELOCITY) { @@ -183,6 +223,18 @@ class TouchScroll return Math.abs(touchCurrentPos.x - touchStartPos.x); } } + + private function getSecondaryDistance():Float + { + if (vertical) + { + return Math.abs(touchCurrentPos.x - touchStartPos.x); + } + else + { + return Math.abs(touchCurrentPos.y - touchStartPos.y); + } + } /** * Get the touch position for tap detection @@ -213,6 +265,22 @@ class TouchScroll { return isScrolling || Math.abs(scrollVelocity) > MOMENTUM_MIN_VELOCITY; } + + /** + * Check if a scroll gesture ended this frame. + */ + public function didReleaseScroll():Bool + { + return justReleasedScroll; + } + + /** + * Check if finger is currently down. + */ + public function isTouchActive():Bool + { + return activeTouch != null; + } /** * Force stop scrolling and momentum @@ -221,6 +289,7 @@ class TouchScroll { scrollVelocity = 0; isScrolling = false; + justReleasedScroll = false; } public function destroy():Void diff --git a/source/funkin/mobile/backend/native/AndroidNativeDropDown.hx b/source/funkin/mobile/backend/native/AndroidNativeDropDown.hx new file mode 100644 index 00000000000..b9714c97f4b --- /dev/null +++ b/source/funkin/mobile/backend/native/AndroidNativeDropDown.hx @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2026 Lenin + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software. + * + * This Software may not be claimed as the original work of any other + * individual or entity. + * + * Attribution to the original author is appreciated, but is not required. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. + * + * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +package funkin.mobile.backend.native; + +#if android +import haxe.Json; +import lime.system.JNI; + +/** + * JNI bridge for Android native MD3 dropdown component. + */ +class AndroidNativeDropDown +{ + public static inline var NO_SELECTION:Int = -1; + public static inline var CANCELED:Int = -2; + + @:noCompletion private static var _initialized:Bool = false; + @:noCompletion private static var _showDropDown_jni:Dynamic = null; + @:noCompletion private static var _pollSelection_jni:Dynamic = null; + @:noCompletion private static var _isDialogVisible_jni:Dynamic = null; + + @:noCompletion + private static function ensureInit():Bool + { + if (_initialized) + return _showDropDown_jni != null && _pollSelection_jni != null && _isDialogVisible_jni != null; + + _initialized = true; + try + { + _showDropDown_jni = JNI.createStaticMethod( + 'com/leninasto/plusengine/components/DropDown', + 'showDropDown', + '(Ljava/lang/String;Ljava/lang/String;I)Z' + ); + + _pollSelection_jni = JNI.createStaticMethod( + 'com/leninasto/plusengine/components/DropDown', + 'pollSelection', + '()I' + ); + + _isDialogVisible_jni = JNI.createStaticMethod( + 'com/leninasto/plusengine/components/DropDown', + 'isDialogVisible', + '()Z' + ); + } + catch (e:Dynamic) + { + trace('[AndroidNativeDropDown] JNI init failed: ' + e); + } + + return _showDropDown_jni != null && _pollSelection_jni != null && _isDialogVisible_jni != null; + } + + public static function show(title:String, items:Array, selectedIndex:Int):Bool + { + if (items == null || items.length == 0) + return false; + + if (!ensureInit()) + return false; + + try + { + return _showDropDown_jni(title, Json.stringify(items), selectedIndex); + } + catch (e:Dynamic) + { + trace('[AndroidNativeDropDown] show failed: ' + e); + } + + return false; + } + + public static function pollSelection():Int + { + if (!ensureInit()) + return NO_SELECTION; + + try + { + return _pollSelection_jni(); + } + catch (e:Dynamic) + { + trace('[AndroidNativeDropDown] pollSelection failed: ' + e); + } + + return NO_SELECTION; + } + + public static function isDialogVisible():Bool + { + if (!ensureInit()) + return false; + + try + { + return _isDialogVisible_jni(); + } + catch (e:Dynamic) + { + trace('[AndroidNativeDropDown] isDialogVisible failed: ' + e); + } + + return false; + } +} +#else +class AndroidNativeDropDown +{ + public static inline var NO_SELECTION:Int = -1; + public static inline var CANCELED:Int = -2; + + public static function show(title:String, items:Array, selectedIndex:Int):Bool + return false; + + public static function pollSelection():Int + return NO_SELECTION; + + public static function isDialogVisible():Bool + return false; +} +#end \ No newline at end of file diff --git a/source/funkin/mobile/backend/native/WavyTimebar.hx b/source/funkin/mobile/backend/native/WavyTimebar.hx new file mode 100644 index 00000000000..2124b514819 --- /dev/null +++ b/source/funkin/mobile/backend/native/WavyTimebar.hx @@ -0,0 +1,299 @@ +/* + * Copyright (C) 2026 Lenin + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software. + * + * This Software may not be claimed as the original work of any other + * individual or entity. + * + * Attribution to the original author is appreciated, but is not required. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. + * + * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +package funkin.mobile.backend.native; + +#if android +import lime.system.JNI; +#end + +/** + * Haxe wrapper for Material3 WavyProgressIndicator timebar + * Provides cross-platform interface to native Android Compose UI component + * + * Usage in PlayState: + * ```haxe + * // On create + * WavyTimebar.initialize(); + * + * // In update loop + * var progress = Conductor.songPosition / FlxG.sound.music.length; + * WavyTimebar.setProgress(progress); + * + * // On destroy + * WavyTimebar.destroy(); + * ``` + */ +class WavyTimebar +{ + // JNI method handles (lazy initialization) + #if android + private static var initialize_jni:Dynamic = null; + private static var destroy_jni:Dynamic = null; + private static var setProgress_jni:Dynamic = null; + private static var show_jni:Dynamic = null; + private static var hide_jni:Dynamic = null; + private static var setAlpha_jni:Dynamic = null; + private static var setLayout_jni:Dynamic = null; + private static var isReady_jni:Dynamic = null; + #end + + private static var _initialized:Bool = false; + + /** + * Initialize the wavy timebar overlay + * Call once when PlayState starts + */ + public static function initialize():Void + { + #if android + if (_initialized) return; + + try + { + if (initialize_jni == null) + { + initialize_jni = JNI.createStaticMethod( + 'com/leninasto/plusengine/PlusEngineExtension', + 'initializeTimebar', + '()V' + ); + } + + initialize_jni(); + _initialized = true; + } + catch (e:Dynamic) + { + trace('[WavyTimebar] Failed to initialize: ' + e); + } + #end + } + + /** + * Destroy timebar and clean up resources + * Call when leaving PlayState + */ + public static function destroy():Void + { + #if android + if (!_initialized) return; + + try + { + if (destroy_jni == null) + { + destroy_jni = JNI.createStaticMethod( + 'com/leninasto/plusengine/PlusEngineExtension', + 'destroyTimebar', + '()V' + ); + } + + destroy_jni(); + _initialized = false; + } + catch (e:Dynamic) + { + trace('[WavyTimebar] Failed to destroy: ' + e); + } + #end + } + + /** + * Update timebar progress + * @param progress Value from 0.0 (song start) to 1.0 (song end) + */ + public static function setProgress(progress:Float):Void + { + #if android + if (!_initialized) return; + + try + { + if (setProgress_jni == null) + { + setProgress_jni = JNI.createStaticMethod( + 'com/leninasto/plusengine/PlusEngineExtension', + 'setTimebarProgress', + '(F)V' + ); + } + + setProgress_jni(progress); + } + catch (e:Dynamic) + { + trace('[WavyTimebar] Failed to set progress: ' + e); + } + #end + } + + /** + * Show timebar (fade in to full visibility) + */ + public static function show():Void + { + #if android + if (!_initialized) return; + + try + { + if (show_jni == null) + { + show_jni = JNI.createStaticMethod( + 'com/leninasto/plusengine/PlusEngineExtension', + 'showTimebar', + '()V' + ); + } + + show_jni(); + } + catch (e:Dynamic) + { + trace('[WavyTimebar] Failed to show: ' + e); + } + #end + } + + /** + * Hide timebar (fade out to invisible) + */ + public static function hide():Void + { + #if android + if (!_initialized) return; + + try + { + if (hide_jni == null) + { + hide_jni = JNI.createStaticMethod( + 'com/leninasto/plusengine/PlusEngineExtension', + 'hideTimebar', + '()V' + ); + } + + hide_jni(); + } + catch (e:Dynamic) + { + trace('[WavyTimebar] Failed to hide: ' + e); + } + #end + } + + /** + * Set timebar alpha/visibility + * @param alpha 0.0 = invisible, 1.0 = fully visible + */ + public static function setAlpha(alpha:Float):Void + { + #if android + if (!_initialized) return; + + try + { + if (setAlpha_jni == null) + { + setAlpha_jni = JNI.createStaticMethod( + 'com/leninasto/plusengine/PlusEngineExtension', + 'setTimebarAlpha', + '(F)V' + ); + } + + setAlpha_jni(alpha); + } + catch (e:Dynamic) + { + trace('[WavyTimebar] Failed to set alpha: ' + e); + } + #end + } + + /** + * Configure native timebar layout. + * @param widthPercent Relative width in [0.2, 1.0] + * @param yPx Top margin in physical pixels + */ + public static function setLayout(widthPercent:Float, yPx:Float):Void + { + #if android + if (!_initialized) return; + + try + { + if (setLayout_jni == null) + { + setLayout_jni = JNI.createStaticMethod( + 'com/leninasto/plusengine/PlusEngineExtension', + 'setTimebarLayout', + '(FF)V' + ); + } + + setLayout_jni(widthPercent, yPx); + } + catch (e:Dynamic) + { + trace('[WavyTimebar] Failed to set layout: ' + e); + } + #end + } + + /** + * Check if timebar is ready to use + * @return true if initialized and ready + */ + public static function isReady():Bool + { + #if android + if (!_initialized) return false; + + try + { + if (isReady_jni == null) + { + isReady_jni = JNI.createStaticMethod( + 'com/leninasto/plusengine/PlusEngineExtension', + 'isTimebarReady', + '()Z' + ); + } + + return isReady_jni(); + } + catch (e:Dynamic) + { + trace('[WavyTimebar] Failed to check ready state: ' + e); + return false; + } + #else + return false; + #end + } +} diff --git a/source/funkin/mobile/objects/TouchButton.hx b/source/funkin/mobile/objects/TouchButton.hx index 42bd238c1a2..608911c66e4 100644 --- a/source/funkin/mobile/objects/TouchButton.hx +++ b/source/funkin/mobile/objects/TouchButton.hx @@ -300,6 +300,10 @@ class TypedTouchButton extends FlxSprite implements IFlxInput function checkInput(pointer:FlxPointer, input:IFlxInput, justPressedPosition:FlxPoint, camera:FlxCamera):Bool { + // Early exit if camera is null + if (camera == null) + return false; + if (maxInputMovement != Math.POSITIVE_INFINITY && justPressedPosition.distanceTo(pointer.getScreenPosition(FlxPoint.weak())) > maxInputMovement && input == currentInput) diff --git a/source/funkin/modding/ModsMenuState.hx b/source/funkin/modding/ModsMenuState.hx index e1384824b9e..073df958afc 100644 --- a/source/funkin/modding/ModsMenuState.hx +++ b/source/funkin/modding/ModsMenuState.hx @@ -38,6 +38,8 @@ class ModsMenuState extends MusicBeatState var buttonToggleMainY:Float = 0; // Posición Y principal para los botones toggle var buttonToggleSecondY:Float = 0; // Posición Y secundaria para el botón oculto + var buttonToggleHiddenY:Float = 0; // Posición fuera de pantalla para ocultar con tween + var toggleButtonsInitialized:Bool = false; var bgTitle:FlxSprite; var bgDescription:FlxSprite; @@ -121,6 +123,7 @@ class ModsMenuState extends MusicBeatState // Establecer posiciones para los botones toggle buttonToggleMainY = buttonReload.y + buttonReload.bg.height + 20; buttonToggleSecondY = buttonToggleMainY + buttonHeight + 20; + buttonToggleHiddenY = FlxG.height + buttonHeight + 40; /*buttonModFolder = new MenuButton(buttonX, buttonToggleMainY, buttonWidth, buttonHeight, "MODS FOLDER", function() { var modFolder = Paths.mods(); @@ -135,16 +138,7 @@ class ModsMenuState extends MusicBeatState buttonEnableAll = new MenuButton(buttonX, buttonToggleMainY, buttonWidth, buttonHeight, Language.getPhrase('enable_all_button', 'ENABLE ALL'), function() { buttonEnableAll.ignoreCheck = false; - for (mod in modsGroup.members) - { - if(modsList.disabled.contains(mod.folder)) - { - modsList.disabled.remove(mod.folder); - modsList.enabled.push(mod.folder); - mod.icon.color = FlxColor.WHITE; - mod.text.color = FlxColor.WHITE; - } - } + setAllModsState(true); updateModDisplayData(); checkToggleButtons(); FlxG.sound.play(Paths.sound('scrollMenu'), 0.6); @@ -155,16 +149,7 @@ class ModsMenuState extends MusicBeatState buttonDisableAll = new MenuButton(buttonX, buttonToggleSecondY, buttonWidth, buttonHeight, Language.getPhrase('disable_all_button', 'DISABLE ALL'), function() { buttonDisableAll.ignoreCheck = false; - for (mod in modsGroup.members) - { - if(modsList.enabled.contains(mod.folder)) - { - modsList.enabled.remove(mod.folder); - modsList.disabled.push(mod.folder); - mod.icon.color = 0xFFFF6666; - mod.text.color = FlxColor.GRAY; - } - } + setAllModsState(false); updateModDisplayData(); checkToggleButtons(); FlxG.sound.play(Paths.sound('scrollMenu'), 0.6); @@ -806,25 +791,78 @@ class ModsMenuState extends MusicBeatState { var hasDisabledMods = modsList.disabled.length > 0; var hasEnabledMods = modsList.enabled.length > 0; - - // Enable All button - siempre en posición principal - buttonEnableAll.y = buttonToggleMainY; - if (hasDisabledMods) { - buttonEnableAll.alpha = 1; - buttonEnableAll.visible = buttonEnableAll.enabled = buttonEnableAll.active = true; - } else { - buttonEnableAll.alpha = 0; - buttonEnableAll.visible = buttonEnableAll.enabled = buttonEnableAll.active = false; + + var showEnableAll:Bool = hasDisabledMods; + var showDisableAll:Bool = !showEnableAll && hasEnabledMods; + + buttonEnableAll.visible = true; + buttonDisableAll.visible = true; + buttonEnableAll.alpha = 1; + buttonDisableAll.alpha = 1; + + buttonEnableAll.enabled = buttonEnableAll.active = showEnableAll; + buttonDisableAll.enabled = buttonDisableAll.active = showDisableAll; + + if (!buttonEnableAll.enabled) + { + buttonEnableAll.ignoreCheck = false; + buttonEnableAll.onFocus = false; } - - // Disable All button - siempre en posición secundaria - buttonDisableAll.y = buttonToggleSecondY; - if (hasEnabledMods) { - buttonDisableAll.alpha = 1; - buttonDisableAll.visible = buttonDisableAll.enabled = buttonDisableAll.active = true; - } else { - buttonDisableAll.alpha = 0; - buttonDisableAll.visible = buttonDisableAll.enabled = buttonDisableAll.active = false; + if (!buttonDisableAll.enabled) + { + buttonDisableAll.ignoreCheck = false; + buttonDisableAll.onFocus = false; + } + + animateToggleButtons(showEnableAll, !toggleButtonsInitialized); + toggleButtonsInitialized = true; + } + + function animateToggleButtons(showEnableAll:Bool, instant:Bool = false):Void + { + var duration:Float = instant ? 0 : 0.2; + + var enableTargetY:Float = showEnableAll ? buttonToggleMainY : buttonToggleHiddenY; + var disableTargetY:Float = showEnableAll ? buttonToggleHiddenY : buttonToggleMainY; + + FlxTween.cancelTweensOf(buttonEnableAll); + FlxTween.cancelTweensOf(buttonDisableAll); + + if (instant) + { + buttonEnableAll.y = enableTargetY; + buttonDisableAll.y = disableTargetY; + } + else + { + FlxTween.tween(buttonEnableAll, {y: enableTargetY}, duration, {ease: FlxEase.quadOut}); + FlxTween.tween(buttonDisableAll, {y: disableTargetY}, duration, {ease: FlxEase.quadOut}); + } + } + + function setAllModsState(enableAll:Bool):Void + { + modsList.enabled = []; + modsList.disabled = []; + + for (mod in modsGroup.members) + { + if (mod == null || mod.folder == null || mod.folder.trim().length < 1) continue; + if (enableAll) modsList.enabled.push(mod.folder); + else modsList.disabled.push(mod.folder); + } + + syncAllModVisualStates(); + } + + function syncAllModVisualStates():Void + { + for (mod in modsGroup.members) + { + if (mod == null) continue; + var isDisabled:Bool = modsList.disabled.contains(mod.folder); + mod.icon.color = isDisabled ? 0xFFFF6666 : FlxColor.WHITE; + mod.text.color = isDisabled ? FlxColor.GRAY : FlxColor.WHITE; } } diff --git a/source/funkin/modding/scripting/psychlua/WindowTweens.hx b/source/funkin/modding/scripting/psychlua/WindowTweens.hx index 61385354152..762fd0c70d0 100644 --- a/source/funkin/modding/scripting/psychlua/WindowTweens.hx +++ b/source/funkin/modding/scripting/psychlua/WindowTweens.hx @@ -140,7 +140,7 @@ class WindowTweens { public static function getWindowWidth():Int { #if windows - return WindowsCPP.getWindowWidth(); + return Std.int(Capabilities.screenResolutionX); #else return FlxG.width; #end @@ -148,7 +148,7 @@ class WindowTweens { public static function getWindowHeight():Int { #if windows - return WindowsCPP.getWindowHeight(); + return Std.int(Capabilities.screenResolutionY); #else return FlxG.height; #end @@ -157,8 +157,8 @@ class WindowTweens { public static function centerWindow() { #if windows var window = Lib.current.stage.window; - var screenWidth = WindowsCPP.getScreenWidth(); - var screenHeight = WindowsCPP.getScreenHeight(); + var screenWidth = Std.int(Capabilities.screenResolutionX); + var screenHeight = Std.int(Capabilities.screenResolutionY); window.x = Std.int((screenWidth - window.width) / 2); window.y = Std.int((screenHeight - window.height) / 2); #end @@ -204,8 +204,8 @@ class WindowTweens { public static function randomizeWindowPosition(minX:Int = 0, maxX:Int = -1, minY:Int = 0, maxY:Int = -1) { #if windows var window = Lib.current.stage.window; - var screenWidth = WindowsCPP.getScreenWidth(); - var screenHeight = WindowsCPP.getScreenHeight(); + var screenWidth = Std.int(Capabilities.screenResolutionX); + var screenHeight = Std.int(Capabilities.screenResolutionY); if (maxX == -1) maxX = Std.int(screenWidth - window.width); if (maxY == -1) maxY = Std.int(screenHeight - window.height); diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index 6f9976a78f1..56d4a0d0172 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -46,6 +46,7 @@ import funkin.graphics.VideoSprite; import funkin.play.components.JudCounter; import funkin.play.notes.Note.EventNote; import funkin.play.stage.*; +import funkin.mobile.backend.native.WavyTimebar; import lenin.PreloadedChartNote; import lenin.HeavyChartManager; import lenin.NoteSpawner; @@ -322,9 +323,11 @@ class PlayState extends MusicBeatState public var cpuControlled:Bool = false; public var practiceMode:Bool = false; public var perfectMode:Bool = false; // Perfect Mode - miss on anything below Sick + public var opponentDrain:Bool = false; // Opponent Mode - how much health the opponent drains on hit public var playOpponent:Bool = false; // Opponent Mode - play as opponent public var noDropPenalty:Bool = false; // Hold drops don't cause misses public var pressMissDamage:Float = 0.05; + public var OPPONENT_DRAIN_FLOOR:Float = 0.2; // Minimum health when opponent drain is active public var botplaySine:Float = 0; public var botplayTxt:FlxText; @@ -410,6 +413,14 @@ class PlayState extends MusicBeatState var TIME_UPDATE_INTERVAL:Float = 1.0; // Actualizar cada segundo var debugUpdateTimer:Float = 0; var DEBUG_UPDATE_INTERVAL:Float = 0.1; // Actualizar cada 100ms + var adaptivePerformanceEnabled:Bool = true; + var adaptivePerfTier:Int = 0; // 0 = normal, 1 = stressed, 2 = critical + var adaptivePerfFrameAvg:Float = 1 / 60; + var adaptivePerfSampleAccum:Float = 0; + var adaptiveIconUpdateAccum:Float = 0; + var adaptiveIconUpdateInterval:Float = 0; + var adaptiveBreakTimerInterval:Float = 0.12; + var adaptiveHeavySpawnCap:Int = 50; var missSpritesPool:Array = []; var MAX_MISS_SPRITES:Int = 3; var endCountdownText:FlxText = null; @@ -419,6 +430,20 @@ class PlayState extends MusicBeatState // Break Timer Feature variables var breakTimerText:FlxText = null; var lastBreakTimerValue:Float = -1; + var breakTimerUpdateAccum:Float = 0; + var breakTimerNextNoteTime:Float = -1; + + #if android + var nativeTimebarUpdateAccum:Float = 0; + var lastNativeTimebarProgress:Float = -1; + var nativeWavyTimebarEnabled:Bool = false; + var lowEndEventMode:Bool = false; + var lowEndEventAccumulator:Float = 0; + static inline var LOW_END_EVENT_TICK:Float = 1 / 45; + static inline var LOW_END_EVENT_BURST:Int = 6; + static inline var NATIVE_TIMEBAR_UPDATE_INTERVAL:Float = 1 / 20; // 20 FPS updates are enough for smooth UI + static inline var NATIVE_TIMEBAR_MIN_DELTA:Float = 0.003; + #end #if windows // Window border color tween system (Slushi Engine method) @@ -666,6 +691,7 @@ class PlayState extends MusicBeatState perfectMode = ClientPrefs.getGameplaySetting('perfect'); playOpponent = ClientPrefs.getGameplaySetting('opponentplay'); noDropPenalty = ClientPrefs.getGameplaySetting('nodroppenalty'); + opponentDrain = ClientPrefs.getGameplaySetting('opponentdrain'); cpuControlled = ClientPrefs.getGameplaySetting('botplay'); guitarHeroSustains = ClientPrefs.data.guitarHeroSustains; @@ -949,7 +975,8 @@ class PlayState extends MusicBeatState timeTxt.alpha = 1; // Alpha siempre visible timeTxt.borderSize = 2; timeTxt.visible = updateTime = showTime; - if(ClientPrefs.data.downScroll) timeTxt.y = FlxG.height - 44; + if(ClientPrefs.data.downScroll) timeTxt.y = FlxG.height - 48; + else timeTxt.y += 6; if(ClientPrefs.data.timeBarType == 'Song Name') timeTxt.text = SONG.song; timeBar = new Bar(0, timeTxt.y + (timeTxt.height / 4), 'timeBar', function() return songPercent, 0, 1); @@ -961,6 +988,23 @@ class PlayState extends MusicBeatState uiGroup.add(timeBar); uiGroup.add(timeTxt); + #if android + nativeWavyTimebarEnabled = showTime && !ClientPrefs.data.hideHud && !isNotITG && ClientPrefs.data.useNativeWavyTimebar; + var showNativeTimebar:Bool = nativeWavyTimebarEnabled; + if (nativeWavyTimebarEnabled) + { + timeBar.visible = false; + } + + var timebarWidthPercent:Float = FlxMath.bound(timeBar.width / FlxG.width, 0.2, 1.0); + WavyTimebar.initialize(); + WavyTimebar.setLayout(timebarWidthPercent, timeBar.y); + WavyTimebar.setProgress(0); + WavyTimebar.setAlpha(showNativeTimebar ? 1 : 0); + if (showNativeTimebar) WavyTimebar.show(); + else WavyTimebar.hide(); + #end + // Lyric text - centered on screen, above everything on HUD lyricText = new FlxText(0, FlxG.height * 0.75, FlxG.width, "", 32); lyricText.setFormat(Paths.font("phantom.ttf"), 32, FlxColor.WHITE, CENTER, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); @@ -1122,7 +1166,7 @@ class PlayState extends MusicBeatState botplayTxt.scrollFactor.set(); botplayTxt.borderSize = 1.25; - // Actualiza el texto según el modo activo + // Update text according to the active mode if (cpuControlled) botplayTxt.text = Language.getPhrase("botplay", "Botplay").toUpperCase(); else if (practiceMode) @@ -1134,6 +1178,7 @@ class PlayState extends MusicBeatState botplayTxt.visible = (cpuControlled || practiceMode || perfectMode || playOpponent); uiGroup.add(botplayTxt); + if(ClientPrefs.data.downScroll) botplayTxt.y = healthBar.y + 70; @@ -1383,8 +1428,9 @@ class PlayState extends MusicBeatState } #end - // Si no hay función onInitModchart, no inicializar el manager - if (!hasModchartFunction) { + // If no onInitModchart function found and the chart does not force-enable modcharts, skip initialization + var forcedByChart:Bool = (PlayState.SONG != null && PlayState.SONG.useModcharts == true); + if (!hasModchartFunction && !forcedByChart) { //trace("No onInitModchart function found - modchart manager not initialized"); return; } @@ -2009,8 +2055,8 @@ class PlayState extends MusicBeatState } /** - * Permite a los scripts resetear el control del scoreTxt al motor - * Llama a esta función desde Lua con: callMethod('resetScoreTxtOverride') + * Allows scripts to return scoreTxt control to the engine. + * Call from Lua with: callMethod('resetScoreTxtOverride') */ public function resetScoreTxtOverride():Void { scoreTxtOverridden = false; @@ -2029,40 +2075,72 @@ class PlayState extends MusicBeatState public dynamic function updateScoreText() { - // Si es un chart de StepMania, actualizar UI personalizado + // If this is a StepMania chart, update custom UI. if (isStepManiaChart) { updateStepManiaUI(); return; } - // Wife3 estándar: rango 0-100% + // Standard Wife3 range: 0-100%. var percent:Float = CoolUtil.floorDecimal(ratingPercent * 100, 2); + + var str:String = ''; - // Formateo estándar (sin soporte para >100%) + // Standard formatting (no support for >100%). var percentDisplay:String = Std.string(percent) + '%'; - - var str:String = percentDisplay + ' / ' + ratingName + ' [' + ratingFC + ']'; - var scoreStr:String = ClientPrefs.data.abbreviateScore ? abbreviateScore(songScore) : Std.string(songScore); + if (ClientPrefs.data.usePsychScoreText) + { + str = Language.getPhrase('rating_$ratingName', ratingName); + if(totalPlayed != 0) + str += ' (${percent}%) - ' + Language.getPhrase(ratingFC); + } + else + { + str = percentDisplay + ' / ' + ratingName + ' [' + ratingFC + ']'; + + } + + if (ClientPrefs.data.usePsychScoreText) + { + var psychScore:String; + if(!instakillOnMiss) + psychScore = Language.getPhrase('score_text_legacy', 'Score: {1} | Misses: {2} | Rating: {3}', [scoreStr, songMisses, str]); + else + psychScore = Language.getPhrase('score_text_instakill_legacy', 'Score: {1} | Rating: {2}', [scoreStr, str]); + + if (scoreTxt.text != lastScoreTxtContent && scoreTxt.text != psychScore) { + scoreTxtOverridden = true; + } + + if (!scoreTxtOverridden) { + scoreTxt.text = psychScore; + lastScoreTxtContent = psychScore; + } + return; + } + var tempScore:String; if(!instakillOnMiss) { - // Determinar qué contador mostrar + // Choose which miss-related counter to display. var missLabel:String = ClientPrefs.data.badShitBreakCombo ? Language.getPhrase('combo_breaks', 'Combo Breaks') : Language.getPhrase('misses', 'Misses'); var missCount:Int = ClientPrefs.data.badShitBreakCombo ? comboBreaks : songMisses; tempScore = Language.getPhrase('score_text', 'Score: {1} | {2}: {3} | Rating: {4} | TPS: {5}/{6}', [scoreStr, missLabel, missCount, str, nps, maxNPS]); } else + { tempScore = Language.getPhrase('score_text_instakill', 'Score: {1} | Rating: {2} | TPS: {3}/{4}', [scoreStr, str, nps, maxNPS]); - - // Detectar si un script modificó el texto externamente + } + + // Detect if a script changed the text externally. if (scoreTxt.text != lastScoreTxtContent && scoreTxt.text != tempScore) { scoreTxtOverridden = true; } - // Solo actualizar si no ha sido sobrescrito por un script + // Only update if it wasn't overridden by a script. if (!scoreTxtOverridden) { scoreTxt.text = tempScore; lastScoreTxtContent = tempScore; @@ -2162,7 +2240,7 @@ class PlayState extends MusicBeatState if(!ClientPrefs.data.scoreZoom) return; - // Para charts StepMania, no animar el contador (el score se actualiza instantáneamente) + // For StepMania charts, do not animate score text (score updates instantly). if (isStepManiaChart) { return; } @@ -2180,12 +2258,15 @@ class PlayState extends MusicBeatState } public function doTimeBump():Void { + if(!ClientPrefs.data.timeBump) + return; + if(timeTxtTween != null) timeTxtTween.cancel(); timeTxt.scale.set(1.5, 1.5); timeTxtTween = FlxTween.tween(timeTxt.scale, {x: 1, y: 1}, 0.3, { - ease: FlxEase.expoOut, // <-- Easing suave + ease: FlxEase.expoOut, onComplete: function(twn:FlxTween) { timeTxtTween = null; } @@ -2198,7 +2279,7 @@ class PlayState extends MusicBeatState versionText.scale.set(1.5, 1.5); versionTextTween = FlxTween.tween(versionText.scale, {x: 1, y: 1}, 0.3, { - ease: FlxEase.expoOut, // <-- Easing suave + ease: FlxEase.expoOut, onComplete: function(twn:FlxTween) { versionTextTween = null; } @@ -2392,8 +2473,162 @@ class PlayState extends MusicBeatState var sectionsData:Array = PlayState.SONG.notes; var ghostNotesCaught:Int = 0; var daBpm:Float = Conductor.bpm; + var noteDedupMap:Map = []; + var useV2FastPath:Bool = songData.format != null && songData.format.startsWith('psych_v2') && Reflect.hasField(songData, 'notesV2'); + var notesV2:Array = useV2FastPath ? cast Reflect.field(songData, 'notesV2') : null; + var eventsV2:Array = useV2FastPath && Reflect.hasField(songData, 'eventsV2') ? cast Reflect.field(songData, 'eventsV2') : null; + var bpmChangesV2:Array = useV2FastPath && Reflect.hasField(songData, 'bpmChangesV2') ? cast Reflect.field(songData, 'bpmChangesV2') : null; + + if (bpmChangesV2 != null) + bpmChangesV2.sort(function(a, b) return Std.int(a.time - b.time)); + + var getV2BpmAtTime = function(timeMs:Float):Float + { + if (bpmChangesV2 == null || bpmChangesV2.length == 0) + return songData.bpm; + + var bpm:Float = songData.bpm; + for (change in bpmChangesV2) + { + if (change == null) continue; + if (change.time != null && change.time <= timeMs + 1) + { + if (change.bpm != null) + bpm = change.bpm; + } + else break; + } + return bpm; + }; - for (section in sectionsData) + if (useV2FastPath && notesV2 != null) + { + notesV2.sort(function(a, b) return Std.int(a.t - b.t)); + for (songNote in notesV2) + { + if (songNote == null) continue; + + var spawnTime:Float = songNote.t; + var rawData:Int = songNote.d; + var noteColumn:Int = Std.int(rawData % totalColumns); + var holdLength:Float = songNote.l != null ? songNote.l : 0.0; + var noteType:String = songNote.type != null ? Std.string(songNote.type) : ''; + if (Math.isNaN(holdLength)) + holdLength = 0.0; + + var gottaHitNote:Bool = (rawData < totalColumns); + var dedupKey:String = noteColumn + ':' + (gottaHitNote ? '1' : '0') + ':' + noteType + ':' + Std.int(spawnTime); + var evilNote:Note = noteDedupMap.get(dedupKey); + if (evilNote != null && Math.abs(spawnTime - evilNote.strumTime) < flixel.math.FlxMath.EPSILON) + { + if (evilNote.tail.length > 0) + for (tail in evilNote.tail) + { + tail.destroy(); + unspawnNotes.remove(tail); + } + + evilNote.destroy(); + unspawnNotes.remove(evilNote); + noteDedupMap.remove(dedupKey); + ghostNotesCaught++; + } + + var swagNote:Note = new Note(spawnTime, noteColumn, oldNote); + swagNote.gfNote = false; + swagNote.animSuffix = ''; + swagNote.mustPress = playOpponent ? !gottaHitNote : gottaHitNote; + swagNote.isOpponentMode = playOpponent; + swagNote.sustainLength = holdLength; + swagNote.noteType = noteType; + swagNote.scrollFactor.set(); + + #if mobile + if (ClientPrefs.data.mobileReceptorAlign && MobileData.mode == 4 && !swagNote.mustPress) + { + swagNote.visible = false; + } + #end + + unspawnNotes.push(swagNote); + noteDedupMap.set(dedupKey, swagNote); + + var noteBpm:Float = getV2BpmAtTime(spawnTime); + var curStepCrochet:Float = 60 / noteBpm * 1000 / 4.0; + final roundSus:Int = Math.round(swagNote.sustainLength / curStepCrochet); + if(roundSus > 0) + { + for (susNote in 0...roundSus) + { + oldNote = unspawnNotes[Std.int(unspawnNotes.length - 1)]; + + var sustainNote:Note = new Note(spawnTime + (curStepCrochet * susNote), noteColumn, oldNote, true); + sustainNote.animSuffix = swagNote.animSuffix; + sustainNote.mustPress = swagNote.mustPress; + sustainNote.gfNote = swagNote.gfNote; + sustainNote.noteType = swagNote.noteType; + sustainNote.isOpponentMode = swagNote.isOpponentMode; + sustainNote.scrollFactor.set(); + sustainNote.parent = swagNote; + + #if mobile + if (ClientPrefs.data.mobileReceptorAlign && MobileData.mode == 4 && !sustainNote.mustPress) + { + sustainNote.visible = false; + } + #end + + unspawnNotes.push(sustainNote); + swagNote.tail.push(sustainNote); + + sustainNote.correctionOffset = swagNote.height / 2; + if(!PlayState.isPixelStage) + { + if(oldNote.isSustainNote) + { + oldNote.scale.y *= Note.SUSTAIN_SIZE / oldNote.frameHeight; + oldNote.scale.y /= playbackRate; + oldNote.resizeByRatio(curStepCrochet / Conductor.stepCrochet); + } + + if(ClientPrefs.data.downScroll) + sustainNote.correctionOffset = 0; + } + else if(oldNote.isSustainNote) + { + oldNote.scale.y /= playbackRate; + oldNote.resizeByRatio(curStepCrochet / Conductor.stepCrochet); + } + + if (sustainNote.mustPress) sustainNote.x += FlxG.width / 2; + else if(ClientPrefs.data.middleScroll) + { + sustainNote.x += 310; + if(noteColumn > 1) + sustainNote.x += FlxG.width / 2 + 25; + } + } + } + + if (swagNote.mustPress) + { + swagNote.x += FlxG.width / 2; + } + else if(ClientPrefs.data.middleScroll) + { + swagNote.x += 310; + if(noteColumn > 1) + { + swagNote.x += FlxG.width / 2 + 25; + } + } + if(!noteTypes.contains(swagNote.noteType)) + noteTypes.push(swagNote.noteType); + + oldNote = swagNote; + } + } + else for (section in sectionsData) { if (section.changeBPM != null && section.changeBPM && section.bpm != null && daBpm != section.bpm) daBpm = section.bpm; @@ -2411,21 +2646,21 @@ class PlayState extends MusicBeatState var gottaHitNote:Bool = (songNotes[1] < totalColumns); if (i != 0) { - // CLEAR ANY POSSIBLE GHOST NOTES - for (evilNote in unspawnNotes) { - var matches: Bool = (noteColumn == evilNote.noteData && gottaHitNote == evilNote.mustPress && evilNote.noteType == noteType); - if (matches && Math.abs(spawnTime - evilNote.strumTime) < flixel.math.FlxMath.EPSILON) { - if (evilNote.tail.length > 0) - for (tail in evilNote.tail) - { - tail.destroy(); - unspawnNotes.remove(tail); - } - evilNote.destroy(); - unspawnNotes.remove(evilNote); - ghostNotesCaught++; - //continue; - } + var dedupKey:String = noteColumn + ':' + (gottaHitNote ? '1' : '0') + ':' + noteType + ':' + Std.int(spawnTime); + var evilNote:Note = noteDedupMap.get(dedupKey); + if (evilNote != null && Math.abs(spawnTime - evilNote.strumTime) < flixel.math.FlxMath.EPSILON) + { + if (evilNote.tail.length > 0) + for (tail in evilNote.tail) + { + tail.destroy(); + unspawnNotes.remove(tail); + } + + evilNote.destroy(); + unspawnNotes.remove(evilNote); + noteDedupMap.remove(dedupKey); + ghostNotesCaught++; } } @@ -2452,6 +2687,7 @@ class PlayState extends MusicBeatState #end unspawnNotes.push(swagNote); + noteDedupMap.set(noteColumn + ':' + (gottaHitNote ? '1' : '0') + ':' + noteType + ':' + Std.int(spawnTime), swagNote); var curStepCrochet:Float = 60 / daBpm * 1000 / 4.0; final roundSus:Int = Math.round(swagNote.sustainLength / curStepCrochet); @@ -2529,13 +2765,38 @@ class PlayState extends MusicBeatState } } trace('["${SONG.song.toUpperCase()}" CHART INFO]: Ghost Notes Cleared: $ghostNotesCaught'); - for (event in songData.events) //Event Notes - for (i in 0...event[1].length) - makeEvent(event, i); + if (useV2FastPath && eventsV2 != null) + { + for (event in eventsV2) + { + if (event == null || event.name == null || event.name == 'Camera Focus') + continue; + + var value1:String = ''; + var value2:String = ''; + if (event.v != null) + { + if (Reflect.hasField(event.v, 'val1')) + value1 = Std.string(Reflect.field(event.v, 'val1')); + else + value1 = Std.string(event.v); + + if (Reflect.hasField(event.v, 'val2')) + value2 = Std.string(Reflect.field(event.v, 'val2')); + } + makeEventDirect(event.t, Std.string(event.name), value1, value2); + } + } + else + { + for (event in songData.events) //Event Notes + for (i in 0...event[1].length) + makeEvent(event, i); + } unspawnNotes.sort(sortByTime); - // Heavy Charts Mode: Convertir notas a estructura ligera + // Heavy Charts Mode: Convert notes to lightweight structure useHeavyCharts = HeavyChartManager.shouldUseHeavyCharts(); if (useHeavyCharts && unspawnNotes.length > 0) { @@ -2549,11 +2810,16 @@ class PlayState extends MusicBeatState { HeavyChartManager.logChartInfo(songData.song, unspawnNotes.length, false); } + + #if android + lowEndEventMode = funkin.mobile.AndroidOptimizer.getCurrentTier() == 0 && eventNotes.length > 80; + lowEndEventAccumulator = 0; + #end generatedMusic = true; totalNotes = 0; - for (note in unspawnNotes) + for (note in unspawnNotes) { if (!note.isSustainNote && note.mustPress) totalNotes++; @@ -2625,6 +2891,19 @@ class PlayState extends MusicBeatState callOnScripts('onEventPushed', [subEvent.event, subEvent.value1 != null ? subEvent.value1 : '', subEvent.value2 != null ? subEvent.value2 : '', subEvent.strumTime]); } + function makeEventDirect(strumTime:Float, eventName:String, value1:String, value2:String) + { + var subEvent:EventNote = { + strumTime: strumTime + ClientPrefs.data.noteOffset, + event: eventName, + value1: value1, + value2: value2 + }; + eventNotes.push(subEvent); + eventPushed(subEvent); + callOnScripts('onEventPushed', [subEvent.event, subEvent.value1 != null ? subEvent.value1 : '', subEvent.value2 != null ? subEvent.value2 : '', subEvent.strumTime]); + } + public var skipArrowStartTween:Bool = false; //for lua private function generateStaticArrows(player:Int):Void { @@ -2891,6 +3170,55 @@ class PlayState extends MusicBeatState var freezeCamera:Bool = false; var allowDebugKeys:Bool = true; + inline function updateAdaptivePerformance(elapsed:Float):Void + { + if (!adaptivePerformanceEnabled || paused || !startedCountdown) + return; + + var smooth:Float = Math.exp(-elapsed * 8); + adaptivePerfFrameAvg = (adaptivePerfFrameAvg * smooth) + (elapsed * (1 - smooth)); + adaptivePerfSampleAccum += elapsed; + + if (adaptivePerfSampleAccum < 0.25) + return; + + adaptivePerfSampleAccum = 0; + var frameMs:Float = adaptivePerfFrameAvg * 1000; + + switch (adaptivePerfTier) + { + case 0: + if (frameMs > 30) + adaptivePerfTier = 2; + else if (frameMs > 22) + adaptivePerfTier = 1; + case 1: + if (frameMs > 30) + adaptivePerfTier = 2; + else if (frameMs < 18) + adaptivePerfTier = 0; + case 2: + if (frameMs < 24) + adaptivePerfTier = 1; + } + + switch (adaptivePerfTier) + { + case 0: + adaptiveIconUpdateInterval = 0; + adaptiveBreakTimerInterval = 0.12; + adaptiveHeavySpawnCap = 50; + case 1: + adaptiveIconUpdateInterval = 1 / 30; + adaptiveBreakTimerInterval = 0.16; + adaptiveHeavySpawnCap = 32; + case 2: + adaptiveIconUpdateInterval = 1 / 20; + adaptiveBreakTimerInterval = 0.22; + adaptiveHeavySpawnCap = 20; + } + } + override public function update(elapsed:Float) { if(!inCutscene && !paused && !freezeCamera) { @@ -2909,6 +3237,7 @@ class PlayState extends MusicBeatState callOnScripts('onUpdate', [elapsed]); super.update(elapsed); + updateAdaptivePerformance(elapsed); #if VIDEOS_ALLOWED if(videoCutscene != null && videoCutscene.videoSprite != null && videoCutscene.videoSprite.bitmap != null) @@ -2942,10 +3271,28 @@ class PlayState extends MusicBeatState } if (healthBar.bounds.max != null && health > healthBar.bounds.max) - health = healthBar.bounds.max; + { + if (!ClientPrefs.data.smoothHPBug) + { + health = healthBar.bounds.max; + } + else + { + // Keep overflow behavior, but spring back to max health when pressure stops. + var springBack:Float = Math.min(1, elapsed * 6); + health = FlxMath.lerp(health, healthBar.bounds.max, springBack); + if (health - healthBar.bounds.max < 0.0005) + health = healthBar.bounds.max; + } + } - updateIconsScale(elapsed); - updateIconsPosition(); + adaptiveIconUpdateAccum += elapsed; + if (adaptiveIconUpdateInterval <= 0 || adaptiveIconUpdateAccum >= adaptiveIconUpdateInterval) + { + updateIconsScale(elapsed); + updateIconsPosition(); + adaptiveIconUpdateAccum = 0; + } if (startedCountdown && !paused) { @@ -3000,6 +3347,16 @@ class PlayState extends MusicBeatState var curTime:Float = Math.max(0, Conductor.songPosition - ClientPrefs.data.noteOffset); songPercent = (curTime / songLength); + #if android + nativeTimebarUpdateAccum += elapsed; + if (nativeWavyTimebarEnabled && nativeTimebarUpdateAccum >= NATIVE_TIMEBAR_UPDATE_INTERVAL && Math.abs(songPercent - lastNativeTimebarProgress) >= NATIVE_TIMEBAR_MIN_DELTA) + { + WavyTimebar.setProgress(songPercent); + lastNativeTimebarProgress = songPercent; + nativeTimebarUpdateAccum = 0; + } + #end + var songCalc:Float = (songLength - curTime); if(ClientPrefs.data.timeBarType == 'Time Elapsed') songCalc = curTime; @@ -3050,14 +3407,15 @@ class PlayState extends MusicBeatState // TPS/NPS System Update { + var nowMs:Float = Date.now().getTime(); var i = notesHitArray.length - 1; while (i >= 0) { var time:Date = notesHitArray[i]; - if (time != null && time.getTime() + 1000 < Date.now().getTime()) - notesHitArray.remove(time); + if (time != null && time.getTime() + 1000 < nowMs) + notesHitArray.splice(i, 1); else - i = -1; // break the loop + break; i--; } nps = notesHitArray.length; @@ -3081,9 +3439,11 @@ class PlayState extends MusicBeatState camHUD.zoom = FlxMath.lerp(1, camHUD.zoom, Math.exp(-elapsed * 3.125 * camZoomingDecay * playbackRate)); } + #if debug FlxG.watch.addQuick("secShit", curSection); FlxG.watch.addQuick("beatShit", curBeat); FlxG.watch.addQuick("stepShit", curStep); + #end // RESET = Quick Game Over Screen if (!ClientPrefs.data.noReset && controls.RESET && canReset && !inCutscene && startedCountdown && !endingSong) @@ -3124,7 +3484,7 @@ class PlayState extends MusicBeatState } else playerDance(); - + if(notes.length > 0) { if(startedCountdown) @@ -3137,7 +3497,11 @@ class PlayState extends MusicBeatState while(i < notes.length) { var daNote:Note = notes.members[i]; - if(daNote == null) continue; + if(daNote == null) + { + i++; + continue; + } // Track rendering cost (JS Engine optimization) amountOfRenderedNotes += daNote.noteDensity; @@ -3176,7 +3540,7 @@ class PlayState extends MusicBeatState daNote.active = daNote.visible = false; invalidateNote(daNote); } - if(daNote.exists) i++; + i++; } } else @@ -3193,41 +3557,61 @@ class PlayState extends MusicBeatState if (useHeavyCharts && !paused && startedCountdown) spawnHeavyNotes(); - checkEventNote(); + #if android + if (lowEndEventMode) + { + lowEndEventAccumulator += elapsed; + if (lowEndEventAccumulator >= LOW_END_EVENT_TICK) + { + checkEventNote(LOW_END_EVENT_BURST); + lowEndEventAccumulator = 0; + } + } + else + { + checkEventNote(); + } + #else + checkEventNote(); + #end // Break Timer Feature - Show timer when next notes are approaching if (ClientPrefs.data.breakTimer && breakTimerText != null && playerStrums != null && playerStrums.length > 0) { - var nextNoteTime:Float = -1; var currentTime:Float = Conductor.songPosition; - - // Find next player note - for (note in notes) + + breakTimerUpdateAccum += elapsed; + if (breakTimerUpdateAccum >= adaptiveBreakTimerInterval) { - if (note != null && note.mustPress && !note.isSustainNote && !note.wasGoodHit && note.strumTime > currentTime) + breakTimerUpdateAccum = 0; + breakTimerNextNoteTime = -1; + + for (note in notes) { - if (nextNoteTime < 0 || note.strumTime < nextNoteTime) - nextNoteTime = note.strumTime; + if (note != null && note.mustPress && !note.isSustainNote && !note.wasGoodHit && note.strumTime > currentTime) + { + if (breakTimerNextNoteTime < 0 || note.strumTime < breakTimerNextNoteTime) + breakTimerNextNoteTime = note.strumTime; + } } - } - - // Also check unspawned notes - if (unspawnNotes != null && unspawnNotes.length > 0) - { - for (note in unspawnNotes) + + if (unspawnNotes != null && unspawnNotes.length > 0) { - if (note != null && note.mustPress && !note.isSustainNote && note.strumTime > currentTime) + for (note in unspawnNotes) { - if (nextNoteTime < 0 || note.strumTime < nextNoteTime) - nextNoteTime = note.strumTime; - break; // unspawnNotes is sorted, so we can break early + if (note != null && note.mustPress && !note.isSustainNote && note.strumTime > currentTime) + { + if (breakTimerNextNoteTime < 0 || note.strumTime < breakTimerNextNoteTime) + breakTimerNextNoteTime = note.strumTime; + break; + } } } } // Display timer if next note is within 3 seconds - var timeUntilNext:Float = (nextNoteTime - currentTime) / 1000; - if (nextNoteTime > 0 && timeUntilNext >= 0 && timeUntilNext <= 3.0) + var timeUntilNext:Float = (breakTimerNextNoteTime - currentTime) / 1000; + if (breakTimerNextNoteTime > 0 && timeUntilNext >= 0 && timeUntilNext <= 3.0) { breakTimerText.visible = true; @@ -3613,8 +3997,12 @@ class PlayState extends MusicBeatState return false; } - public function checkEventNote() { + public function checkEventNote(?maxEvents:Int = -1) { + var processed:Int = 0; while(eventNotes.length > 0) { + if (maxEvents > -1 && processed >= maxEvents) + return; + var leStrumTime:Float = eventNotes[0].strumTime; if(Conductor.songPosition < leStrumTime) { return; @@ -3630,6 +4018,7 @@ class PlayState extends MusicBeatState triggerEvent(eventNotes[0].event, value1, value2, leStrumTime); eventNotes.shift(); + processed++; } } @@ -4249,6 +4638,10 @@ class PlayState extends MusicBeatState timeBar.visible = false; timeTxt.visible = false; + #if android + WavyTimebar.hide(); + WavyTimebar.setAlpha(0); + #end canPause = false; endingSong = true; camZooming = false; @@ -4269,12 +4662,12 @@ class PlayState extends MusicBeatState #if !switch var percent:Float = ratingPercent; if(Math.isNaN(percent)) percent = 0; - Highscore.saveScore(Song.loadedSongName, songScore, storyDifficulty, percent, playOpponent, ClientPrefs.data.accuracySystem); + if(!cpuControlled) + Highscore.saveScore(Song.loadedSongName, songScore, storyDifficulty, percent, playOpponent, ClientPrefs.data.accuracySystem); #end playbackRate = 1; if (!chartingMode && !cpuControlled && !isStoryMode) { - // INICIAR FREAKYMENU ANTES DE IR A RESULTSSTATE FlxG.sound.playMusic(Paths.music('freakyMenu'), 0.7, true); restoreWindowState(); @@ -4452,7 +4845,11 @@ class PlayState extends MusicBeatState canResync = false; restoreWindowState(); - MusicBeatState.switchState(new FreeplayState()); + if (ClientPrefs.data.newfreeplay) + MusicBeatState.switchState(new FreeplayState()); + else + MusicBeatState.switchState(new funkin.ui.freeplay.FreeplayState_Psych()); + FlxG.sound.playMusic(Paths.music('freakyMenu')); changedDifficulty = false; } @@ -4711,36 +5108,34 @@ class PlayState extends MusicBeatState } #end - if(!cpuControlled) { - songScore += score; - if(!note.ratingDisabled) - { - songHits++; - totalPlayed++; - RecalculateRating(false); - - // Perfect Mode: Miss on anything below Sick! - if (perfectMode && !practiceMode && daRating.name != 'flawless' && daRating.name != 'sick') - { - doDeathCheck(true); - } - } + songScore += score; + if(!note.ratingDisabled) + { + songHits++; + totalPlayed++; + RecalculateRating(false); - // Verificar si Bad o Shit rompen el combo - if (ClientPrefs.data.badShitBreakCombo && (daRating.name == 'bad' || daRating.name == 'shit')) + // Perfect Mode: Miss on anything below Sick! + if (!cpuControlled && perfectMode && !practiceMode && daRating.name != 'flawless' && daRating.name != 'sick') { - combo = 0; - comboBreaks++; // Incrementar contador de combo breaks - showComboBreak(); // Mostrar sprite de combo broken + doDeathCheck(true); } + } + + // Check if Bad or Shit should break combo. + if (ClientPrefs.data.badShitBreakCombo && (daRating.name == 'bad' || daRating.name == 'shit')) + { + combo = 0; + comboBreaks++; // Increase combo break counter. + showComboBreak(); // Show combo broken sprite. + } - if (judgementCounter != null) { - judgementCounter.doComboBump(); - - // Si es un nuevo máximo combo - if (combo > maxCombo) { - judgementCounter.doMaxComboBump(); - } + if (judgementCounter != null) { + judgementCounter.doComboBump(); + + // If this is a new max combo. + if (combo > maxCombo) { + judgementCounter.doMaxComboBump(); } } @@ -5215,10 +5610,10 @@ class PlayState extends MusicBeatState function noteMiss(daNote:Note):Void { //You didn't hit the key and let it go offscreen, also used by Hurt Notes //Dupe note remove - notes.forEachAlive(function(note:Note) { - if (daNote != note && daNote.mustPress && daNote.noteData == note.noteData && daNote.isSustainNote == note.isSustainNote && Math.abs(daNote.strumTime - note.strumTime) < 1) - invalidateNote(note); - }); + notes.forEachAlive(function(note:Note) { + if (daNote != note && daNote.mustPress && daNote.noteData == note.noteData && daNote.isSustainNote == note.isSustainNote && Math.abs(daNote.strumTime - note.strumTime) < 1) + invalidateNote(note); + }); // Safely check for parent and tail var parentNote:Note = daNote.isSustainNote ? daNote.parent : daNote; @@ -5394,7 +5789,15 @@ class PlayState extends MusicBeatState if(opponentVocals.length <= 0) vocals.volume = 1; strumPlayAnim(true, Std.int(Math.abs(note.noteData)), Conductor.stepCrochet * 1.25 / 1000 / playbackRate); note.hitByOpponent = true; - + + // JS Engine-style opponent drain: each opponent head-note drains a small amount of + // player health. Sustains are skipped to avoid instant death at high note densities. + // A floor prevents the player from dying from drain alone (instakill still applies). + if (opponentDrain && !note.isSustainNote && !practiceMode && health > OPPONENT_DRAIN_FLOOR) + { + health = Math.max(OPPONENT_DRAIN_FLOOR, health - note.hitHealth * healthLoss); + } + stagesFunc(function(stage:BaseStage) stage.opponentNoteHit(note)); if (destructiveHUDActive && destructiveHUDMode == 'note') shuffleHUD(); @@ -5501,7 +5904,7 @@ class PlayState extends MusicBeatState djmax_combo++; if(djmax_combo > djmax_maxCombo) djmax_maxCombo = djmax_combo; - popUpScore(note); + popUpScore(note); } var gainHealth:Bool = true; // prevent health gain, *if* sustains are treated as a singular note if (guitarHeroSustains && note.isSustainNote) gainHealth = false; @@ -5579,7 +5982,8 @@ class PlayState extends MusicBeatState if(curStage == 'notitg') return; if(note != null) { - var strum:StrumNote = playerStrums.members[note.noteData]; + var strumGroup:FlxTypedGroup = note.mustPress ? playerStrums : opponentStrums; + var strum:StrumNote = strumGroup.members[note.noteData]; if(strum != null) spawnNoteSplash(strum.x, strum.y, note.noteData, note, strum); } @@ -5588,6 +5992,10 @@ class PlayState extends MusicBeatState public function spawnNoteSplash(x:Float = 0, y:Float = 0, ?data:Int = 0, ?note:Note, ?strum:StrumNote) { // No mostrar splashes en niveles de StepMania NotITG if(curStage == 'notitg') return; + + // If splash alpha is effectively zero (either global or per-note), don't create the splash + var effectiveAlpha:Float = (note != null) ? note.noteSplashData.a : ClientPrefs.data.splashAlpha; + if (effectiveAlpha <= 0) return; var splash:NoteSplash = grpNoteSplashes.recycle(NoteSplash); splash.babyArrow = strum; @@ -5596,6 +6004,10 @@ class PlayState extends MusicBeatState } override function destroy() { + #if android + WavyTimebar.destroy(); + #end + // Limpieza agresiva de memoria antes de destruir (Android) #if android funkin.util.MemoryManager.aggressiveCleanup(); @@ -6233,7 +6645,8 @@ class PlayState extends MusicBeatState public var ratingPercent:Float; public var ratingFC:String; public function RecalculateRating(badHit:Bool = false, scoreBop:Bool = true) { - setOnScripts('score', songScore); + var scoreValueForScripts:Dynamic = ClientPrefs.data.abbreviateScore ? abbreviateScore(songScore) : songScore; + setOnScripts('score', scoreValueForScripts); setOnScripts('misses', songMisses); setOnScripts('hits', songHits); setOnScripts('combo', combo); @@ -6757,7 +7170,9 @@ class PlayState extends MusicBeatState var targetNote:PreloadedChartNote = null; var spawnedCount:Int = 0; var lastNote:Note = null; // Rastrear la última nota para sustains - var maxNotesPerFrame:Int = 50; // Limitar a 50 notas por frame para evitar lag spikes + var maxNotesPerFrame:Int = adaptiveHeavySpawnCap; + if (maxNotesPerFrame < 8) + maxNotesPerFrame = 8; // Spawnear notas mientras haya espacio en el límite dinámico while (notesAddedCount < preloadedNotes.length && limitNC < dynamicNoteLimit && spawnedCount < maxNotesPerFrame) @@ -6814,3 +7229,4 @@ class PlayState extends MusicBeatState } } + diff --git a/source/funkin/play/notes/Note.hx b/source/funkin/play/notes/Note.hx index e3599a50ed8..bf08fda3a05 100644 --- a/source/funkin/play/notes/Note.hx +++ b/source/funkin/play/notes/Note.hx @@ -488,7 +488,8 @@ class Note extends FlxSprite var skinPostfix:String = getNoteSkinPostfix(); var customSkin:String = skin + skinPostfix; // NotITG/Psych skins have no pixel variant - treat them as normal skins on any stage - var isSpecialSkin:Bool = skin.toLowerCase().contains('notitg'); + var skinLower:String = skin.toLowerCase(); + var isSpecialSkin:Bool = skinLower.contains('notitg') || skinLower.contains('psych'); var path:String = (PlayState.isPixelStage && !isSpecialSkin) ? 'pixelUI/' : ''; if(customSkin == _lastValidChecked || Paths.fileExists('images/' + path + customSkin + '.png', IMAGE)) { @@ -538,7 +539,7 @@ class Note extends FlxSprite if(animName != null) animation.play(animName, true); - // Detectar si es NotITG o Psych y bloquear el shader + // Detect NotITG/Psych skins and keep RGB shader disabled for them. if(skin != null) { var skinLower = skin.toLowerCase(); @@ -553,7 +554,7 @@ class Note extends FlxSprite } else { - // Desbloquear shader para skins normales + // Re-enable shader for normal skins. if(rgbShader != null) rgbShader.forceDisabled = false; } @@ -637,8 +638,6 @@ class Note extends FlxSprite { super.destroy(); _lastValidChecked = ''; - - super.destroy(); } public function followStrumNote(myStrum:StrumNote, fakeCrochet:Float, songSpeed:Float = 1) diff --git a/source/funkin/play/notes/NoteSplash.hx b/source/funkin/play/notes/NoteSplash.hx index 96fabfa397f..a23e1fc2d63 100644 --- a/source/funkin/play/notes/NoteSplash.hx +++ b/source/funkin/play/notes/NoteSplash.hx @@ -292,10 +292,8 @@ class NoteSplash extends FlxSprite animation.finishCallback = null; animation.finishCallback = function(name:String) { - if (spawned) // Only kill if still marked as spawned - { - kill(); - } + kill(); + spawned = false; } alpha = ClientPrefs.data.splashAlpha; @@ -347,28 +345,7 @@ class NoteSplash extends FlxSprite if (spawned) { aliveTime += elapsed; - - // Check if animation is finished or broken - var shouldKill:Bool = false; - - if (animation.curAnim == null) - { - // Animation object is null - definitely broken - if (aliveTime >= buggedKillTime) - shouldKill = true; - } - else if (animation.curAnim.finished) - { - // Animation has completed - shouldKill = true; - } - else if (aliveTime >= buggedKillTime) - { - // Animation is taking too long - likely paused/stuck - shouldKill = true; - } - - if (shouldKill) + if (animation.curAnim == null && aliveTime >= buggedKillTime) { kill(); spawned = false; diff --git a/source/funkin/play/notes/StrumNote.hx b/source/funkin/play/notes/StrumNote.hx index 81135cc1dda..227256033c0 100644 --- a/source/funkin/play/notes/StrumNote.hx +++ b/source/funkin/play/notes/StrumNote.hx @@ -44,27 +44,27 @@ class StrumNote extends FlxSprite var customSkin:String = skin + Note.getNoteSkinPostfix(); if(Paths.fileExists('images/$customSkin.png', IMAGE)) skin = customSkin; - // Detectar PRIMERO si es NotITG antes de configurar el shader + // Detect special skins first, before configuring the shader. var isNotITG:Bool = skin.toLowerCase().contains('notitg'); - // Crear el shader + // Create shader. rgbShader = new RGBShaderReference(this, Note.initializeGlobalRGBShader(leData)); rgbShader.enabled = false; if(PlayState.SONG != null && PlayState.SONG.disableNoteRGB) useRGBShader = false; - // Si es NotITG, desactivar shader desde el inicio + // NotITG skins keep RGB shader disabled from the start. if(isNotITG) { useRGBShader = false; animateOnBeat = true; rgbShader.enabled = false; - rgbShader.forceDisabled = true; // BLOQUEAR la activación del shader permanentemente - shader = null; // No aplicar shader + rgbShader.forceDisabled = true; // Keep shader activation blocked. + shader = null; // Do not apply shader. } else { - // Solo asignar colores RGB si NO es NotITG + // Only assign RGB colors when it's not a special skin. var arr:Array = ClientPrefs.data.arrowRGB[leData]; if(PlayState.isPixelStage) arr = ClientPrefs.data.arrowRGBPixel[leData]; @@ -87,30 +87,30 @@ class StrumNote extends FlxSprite public function checkNotITGSkin():Void { - // Verificar si el skin actual contiene "notitg" o "psych" en el nombre + // Check whether the current skin contains "notitg" or "psych". var skinLower:String = texture.toLowerCase(); if(skinLower.contains('notitg') || skinLower.contains('psych')) { - useRGBShader = false; // Desactivar shader RGB para NotITG y Psych - animateOnBeat = true; // Activar animación sincronizada con el beat + useRGBShader = false; // Disable RGB shader for NotITG/Psych skins. + animateOnBeat = true; // Enable beat-synced static animation. - // Desactivar el shader completamente y BLOQUEAR su activación + // Disable shader completely and block reactivation. if(rgbShader != null) { - rgbShader.forceDisabled = true; // BLOQUEAR permanentemente + rgbShader.forceDisabled = true; // Keep blocked permanently. rgbShader.enabled = false; } - // Remover el shader del sprite + // Remove shader from the sprite. shader = null; } else { - // Restaurar valores por defecto si no es NotITG ni Psych + // Restore defaults for regular skins. useRGBShader = true; animateOnBeat = false; if(PlayState.SONG != null && PlayState.SONG.disableNoteRGB) useRGBShader = false; - // Desbloquear el shader para skins normales + // Unblock shader for regular skins. if(rgbShader != null) rgbShader.forceDisabled = false; } @@ -122,7 +122,8 @@ class StrumNote extends FlxSprite if(animation.curAnim != null) lastAnim = animation.curAnim.name; // NotITG/Psych skins have no pixel variant - treat them as normal skins on any stage - var isSpecialSkin:Bool = texture.toLowerCase().contains('notitg'); + var textureLower:String = texture.toLowerCase(); + var isSpecialSkin:Bool = textureLower.contains('notitg') || textureLower.contains('psych'); if(PlayState.isPixelStage && !isSpecialSkin) { @@ -196,7 +197,7 @@ class StrumNote extends FlxSprite playAnim(lastAnim, true); } - // Re-verificar si es NotITG después de recargar + // Re-check special skin after reloading. checkNotITGSkin(); } @@ -226,11 +227,10 @@ class StrumNote extends FlxSprite centerOffsets(); centerOrigin(); } - // Solo activar shader RGB si useRGBShader está habilitado y no es animación estática - // Para NotITG (useRGBShader = false), NUNCA activar el shader + // Only enable RGB shader when allowed and the receptor isn't static. if(useRGBShader && rgbShader != null) rgbShader.enabled = (animation.curAnim != null && animation.curAnim.name != 'static'); else if(rgbShader != null) - rgbShader.enabled = false; // Asegurar que esté desactivado si useRGBShader = false + rgbShader.enabled = false; // Keep disabled when RGB is not allowed. } } diff --git a/source/funkin/play/notes/SustainSplash.hx b/source/funkin/play/notes/SustainSplash.hx index 3bbe0da89ae..617d335072d 100644 --- a/source/funkin/play/notes/SustainSplash.hx +++ b/source/funkin/play/notes/SustainSplash.hx @@ -2,6 +2,7 @@ package funkin.play.notes; import funkin.graphics.animation.PsychAnimationController; import funkin.graphics.shaders.RGBPalette; +import flixel.graphics.frames.FlxAtlasFrames; typedef HoldSplashAnim = { var name:String; @@ -46,6 +47,22 @@ class SustainSplash extends FlxSprite loadHoldSplash(holdSplash); } + + private function safeLoadAtlas(key:String):FlxAtlasFrames + { + if (key == null || key.trim().length < 1) + return null; + + try + { + return Paths.getSparrowAtlas(key); + } + catch (e:Dynamic) + { + trace('[SustainSplash] Failed to load hold splash atlas "' + key + '": ' + Std.string(e)); + } + return null; + } public function loadHoldSplash(?holdSplash:String) { @@ -61,15 +78,21 @@ class SustainSplash extends FlxSprite } texture = holdSplash; - frames = Paths.getSparrowAtlas(texture); + frames = safeLoadAtlas(texture); if (frames == null) { texture = path + defaultHoldSplash + getHoldSplashPostfix(); - frames = Paths.getSparrowAtlas(texture); + frames = safeLoadAtlas(texture); if (frames == null) { texture = path + defaultHoldSplash + '-Vanilla'; - frames = Paths.getSparrowAtlas(texture); + frames = safeLoadAtlas(texture); + if (frames == null) + { + // Keep the object alive without visuals instead of crashing on invalid XML/atlas data. + visible = false; + active = false; + } } } diff --git a/source/funkin/play/substates/PauseSubState.hx b/source/funkin/play/substates/PauseSubState.hx index 45a7711c49b..4e809dac90f 100644 --- a/source/funkin/play/substates/PauseSubState.hx +++ b/source/funkin/play/substates/PauseSubState.hx @@ -455,10 +455,16 @@ class PauseSubState extends MusicBeatSubstate Mods.loadTopMod(); if(PlayState.isStoryMode) + { MusicBeatState.switchState(new StoryMenuState()); + } else - MusicBeatState.switchState(new FreeplayState()); - + { + if (ClientPrefs.data.newfreeplay) + MusicBeatState.switchState(new FreeplayState()); + else + MusicBeatState.switchState(new funkin.ui.freeplay.FreeplayState_Psych()); + } FlxG.sound.playMusic(Paths.music('freakyMenu')); PlayState.changedDifficulty = false; PlayState.chartingMode = false; @@ -698,9 +704,17 @@ class PauseSubState extends MusicBeatSubstate Mods.loadTopMod(); if(PlayState.isStoryMode) + { MusicBeatState.switchState(new StoryMenuState()); + } else - MusicBeatState.switchState(new FreeplayState()); + { + if (ClientPrefs.data.newfreeplay) + MusicBeatState.switchState(new FreeplayState()); + else + MusicBeatState.switchState(new funkin.ui.freeplay.FreeplayState_Psych()); + } + FlxG.sound.playMusic(Paths.music('freakyMenu')); PlayState.changedDifficulty = false; diff --git a/source/funkin/ui/components/PsychUIDropDownMenu.hx b/source/funkin/ui/components/PsychUIDropDownMenu.hx index 59a399dbf7b..dbbdb487407 100644 --- a/source/funkin/ui/components/PsychUIDropDownMenu.hx +++ b/source/funkin/ui/components/PsychUIDropDownMenu.hx @@ -1,6 +1,9 @@ package funkin.ui.components; import funkin.ui.components.PsychUIBox.UIStyleData; +#if android +import funkin.mobile.backend.native.AndroidNativeDropDown; +#end class PsychUIDropDownMenu extends PsychUIInputText { @@ -93,10 +96,65 @@ class PsychUIDropDownMenu extends PsychUIInputText var _prevMouseY:Float = 0; var _touchDragDist:Float = 0; #end + #if android + var waitingNativeSelection:Bool = false; + inline function tryOpenNativeDropDown():Bool + { + if(list == null || list.length <= 0) + return false; + + var nativeSelectedIndex:Int = selectedIndex >= 0 ? selectedIndex : 0; + if(AndroidNativeDropDown.show('Select option', list, nativeSelectedIndex)) + { + waitingNativeSelection = true; + PsychUIInputText.focusOn = null; + FlxG.stage.window.textInputEnabled = false; + showDropDown(false); + return true; + } + + return false; + } + #end override function update(elapsed:Float) { + #if android + if(FlxG.mouse.justPressed) + { + var pressedButton:Bool = FlxG.mouse.overlaps(button, camera); + var pressedField:Bool = FlxG.mouse.overlaps(behindText, camera); + + if(pressedButton || pressedField) + { + button.animation.play('pressed', true); + if(tryOpenNativeDropDown()) + { + return; + } + } + } + #end + var lastFocus = PsychUIInputText.focusOn; super.update(elapsed); + + #if android + if(waitingNativeSelection) + { + var nativeSelection:Int = AndroidNativeDropDown.pollSelection(); + if(nativeSelection >= 0) + { + waitingNativeSelection = false; + if(nativeSelection < list.length) + clickedOn(nativeSelection, list[nativeSelection]); + } + else if(nativeSelection == AndroidNativeDropDown.CANCELED || !AndroidNativeDropDown.isDialogVisible()) + { + waitingNativeSelection = false; + } + } + #end + if(FlxG.mouse.justPressed) { if(FlxG.mouse.overlaps(button, camera)) diff --git a/source/funkin/ui/credits/CreditsState.hx b/source/funkin/ui/credits/CreditsState.hx index 305882f5b69..450e97417ff 100644 --- a/source/funkin/ui/credits/CreditsState.hx +++ b/source/funkin/ui/credits/CreditsState.hx @@ -48,7 +48,7 @@ class CreditsState extends MusicBeatState ['Legacy Odyssey', "", "Co-programmer of Plus Engine", "https://www.youtube.com/@LegacyOdyssey","8E07C2"], ['DaffyToons', "daffytoons", "Failed Attempt at Plus Engine Programmer", "https://github.com/DaffyToons", "0A8451"], ["Andres", "slu", "Creator and owner of several codes used based on the Slushi Engine", "https://github.com/Slushi-Github","8FD9D1"], - ['sirthegamercoder', "sir", 'Indonesian translation and others PRs', '','7FDBFF'], // My Bluesky account has deactivated starting February 18 until March 21 + ['sirthegamercoder', "sir", 'Indonesian translation and others PRs', 'https://bsky.app/profile/stgmd.bsky.social','7FDBFF'], ['Hansuke H', "hansu", 'Vietnamese translation and alphabet sprite', 'https://www.facebook.com/hansuke.hotaroshi', 'FF6C8D'], ['TheoDev', "theo", "Owner, Lead coder of Funkin Modchart", "https://github.com/TheoDevelops", "FFB347"], [''], diff --git a/source/funkin/ui/debug/HoldSplashEditorState.hx b/source/funkin/ui/debug/HoldSplashEditorState.hx index 91410a86648..6bf43fc40f0 100644 --- a/source/funkin/ui/debug/HoldSplashEditorState.hx +++ b/source/funkin/ui/debug/HoldSplashEditorState.hx @@ -873,7 +873,7 @@ class HoldSplashEditorHelpSubState extends MusicBeatSubstate { super.update(elapsed); - if (controls.ACCEPT || controls.BACK #if mobile || FlxG.android.justReleased.BACK #end) + if (controls.ACCEPT || controls.BACK #if android || FlxG.android.justReleased.BACK #end) { FlxG.sound.play(Paths.sound('cancelMenu')); close(); diff --git a/source/funkin/ui/debug/MD3Test1State.hx b/source/funkin/ui/debug/MD3Test1State.hx deleted file mode 100644 index dfe1dc77984..00000000000 --- a/source/funkin/ui/debug/MD3Test1State.hx +++ /dev/null @@ -1,120 +0,0 @@ -package funkin.ui.debug; - -import flixel.FlxG; -import flixel.FlxSprite; -import flixel.text.FlxText; -import flixel.util.FlxColor; -import funkin.ui.MusicBeatState; -import funkin.ui.debug.MD3TestState; -import funkin.ui.debug.MD3Test2State; -import funkin.ui.components.md3.*; - -/** - * MD3 component debug viewer — Page 2/4: Chips, Tabs, Badges. - * Navigate with LEFT/RIGHT arrow keys. Press BACK/ESC to exit. - */ -class MD3Test1State extends MusicBeatState -{ - static inline var PAGE_INDEX:Int = 1; - static inline var TOTAL_PAGES:Int = 7; - - var snackbar:MaterialSnackbar; - var filterChips:Array = []; - - override function create():Void - { - super.create(); - Cursor.show(); - - var bg = new FlxSprite(); - bg.makeGraphic(FlxG.width, FlxG.height, 0xFFFEF7FF); - add(bg); - - buildContent(); - - var pageLabel = new FlxText(0, FlxG.height - 28, FlxG.width, - '← → to navigate Page ${PAGE_INDEX + 1} / $TOTAL_PAGES ESC to exit', 14); - pageLabel.setFormat(Paths.font("phantom.ttf"), 14, 0xFF49454F, CENTER); - add(pageLabel); - - snackbar = new MaterialSnackbar(340); - add(snackbar); - - addTouchPad('LEFT_RIGHT', 'B'); - } - - function sectionLabel(text:String, lx:Float, ly:Float):Void - { - var lbl = new FlxText(lx, ly, 0, text, 12); - lbl.setFormat(Paths.font("phantom.ttf"), 12, 0xFF79747E, LEFT); - add(lbl); - } - - function buildContent():Void - { - sectionLabel("Chips", 20, 12); - - add(new MaterialChip(20, 36, "Assist", ASSIST, false, function() { snackbar.show("Assist chip tapped", 2); })); - add(new MaterialChip(120, 36, "Suggestion", SUGGESTION, false, function() { snackbar.show("Suggestion chip tapped", 2); })); - add(new MaterialChip(260, 36, "Input chip", INPUT, false, null, function() { snackbar.show("Input chip deleted!", 2); })); - - sectionLabel("Filter chips (toggle)", 20, 78); - - var filterLabels = ["Hard", "Expert", "Normal", "Easy"]; - filterChips = []; - for (i in 0...filterLabels.length) - { - var chip = new MaterialChip(20 + i * 106, 100, filterLabels[i], FILTER, i == 0, function() - { - var selected = filterChips.filter(c -> c.selected).map(c -> c.label); - snackbar.show("Selected: " + (selected.length > 0 ? selected.join(", ") : "none"), 2); - }); - filterChips.push(chip); - add(chip); - } - - sectionLabel("Tabs (Primary)", 20, 152); - - var tabsPrimary = new MaterialTabs(20, 174, ["Songs", "Characters", "Stages", "Options"], PRIMARY, 460, function(i, name) - { - snackbar.show('Tab: "$name"', 2); - }); - add(tabsPrimary); - - sectionLabel("Tabs (Secondary)", 20, 232); - - var tabsSec = new MaterialTabs(20, 254, ["Info", "Settings", "Logs"], SECONDARY, 300, function(i, name) - { - snackbar.show('Secondary tab: "$name"', 2); - }); - add(tabsSec); - - sectionLabel("Badge", 20, 316); - - // Badge centers are all aligned to y_center = 347. - // Dot (6 px tall): y = 347 - 3 = 344. Numeric (16 px tall): y = 347 - 8 = 339. - var dotBadge = new MaterialBadge(20, 344, -1); - add(dotBadge); - sectionLabel("dot", 30, 340); - - var numBadge = new MaterialBadge(88, 339, 5); - add(numBadge); - sectionLabel("5", 108, 340); - - var bigBadge = new MaterialBadge(158, 339, 1337); // displays "999+" - add(bigBadge); - sectionLabel("1337 →999+", 210, 340); - } - - override function update(elapsed:Float):Void - { - super.update(elapsed); - - if (controls.UI_LEFT_P || FlxG.keys.justPressed.LEFT) - FlxG.switchState(new MD3TestState()); - if (controls.UI_RIGHT_P || FlxG.keys.justPressed.RIGHT) - FlxG.switchState(new MD3Test2State()); - if (controls.BACK) - FlxG.switchState(new funkin.ui.title.TitleState()); - } -} diff --git a/source/funkin/ui/debug/MD3Test2State.hx b/source/funkin/ui/debug/MD3Test2State.hx deleted file mode 100644 index 5147d38d56b..00000000000 --- a/source/funkin/ui/debug/MD3Test2State.hx +++ /dev/null @@ -1,143 +0,0 @@ -package funkin.ui.debug; - -import flixel.FlxG; -import flixel.FlxSprite; -import flixel.text.FlxText; -import flixel.util.FlxColor; -import funkin.ui.MusicBeatState; -import funkin.ui.debug.MD3Test1State; -import funkin.ui.debug.MD3Test3State; -import funkin.ui.components.md3.*; - -/** - * MD3 component debug viewer — Page 3/4: Cards, Progress Indicators. - * Navigate with LEFT/RIGHT arrow keys. Press BACK/ESC to exit. - */ -class MD3Test2State extends MusicBeatState -{ - static inline var PAGE_INDEX:Int = 2; - static inline var TOTAL_PAGES:Int = 7; - - var snackbar:MaterialSnackbar; - - // Determinate progress indicators animated in update() - var linearDet:MaterialProgressIndicator; - var circularDet:MaterialProgressIndicator; - var progressTimer:Float = 0; - - override function create():Void - { - super.create(); - Cursor.show(); - - var bg = new FlxSprite(); - bg.makeGraphic(FlxG.width, FlxG.height, 0xFFFEF7FF); - add(bg); - - buildContent(); - - var pageLabel = new FlxText(0, FlxG.height - 28, FlxG.width, - '← → to navigate Page ${PAGE_INDEX + 1} / $TOTAL_PAGES ESC to exit', 14); - pageLabel.setFormat(Paths.font("phantom.ttf"), 14, 0xFF49454F, CENTER); - add(pageLabel); - - snackbar = new MaterialSnackbar(340); - add(snackbar); - - addTouchPad('LEFT_RIGHT', 'B'); - } - - function sectionLabel(text:String, lx:Float, ly:Float):Void - { - var lbl = new FlxText(lx, ly, 0, text, 12); - lbl.setFormat(Paths.font("phantom.ttf"), 12, 0xFF79747E, LEFT); - add(lbl); - } - - function buildContent():Void - { - sectionLabel("Cards", 20, 12); - - // Elevated card - var cardEl = new MaterialCard(20, 34, ELEVATED, 180, 100, function() { snackbar.show("Elevated card clicked!", 2); }); - var elLbl = new FlxText(12, 12, 156, "Elevated Card\nClick me!", 13); - elLbl.setFormat(Paths.font("phantom.ttf"), 13, 0xFF1C1B1F, LEFT); - cardEl.addContent(elLbl); - add(cardEl); - - // Filled card - var cardFi = new MaterialCard(218, 34, FILLED, 180, 100, function() { snackbar.show("Filled card clicked!", 2); }); - var fiLbl = new FlxText(12, 12, 156, "Filled Card\nClick me!", 13); - fiLbl.setFormat(Paths.font("phantom.ttf"), 13, 0xFF1C1B1F, LEFT); - cardFi.addContent(fiLbl); - add(cardFi); - - // Outlined card - var cardOu = new MaterialCard(416, 34, OUTLINED, 180, 100, function() { snackbar.show("Outlined card clicked!", 2); }); - var ouLbl = new FlxText(12, 12, 156, "Outlined Card\nClick me!", 13); - ouLbl.setFormat(Paths.font("phantom.ttf"), 13, 0xFF1C1B1F, LEFT); - cardOu.addContent(ouLbl); - add(cardOu); - - sectionLabel("Progress Linear", 20, 152); - - // Linear determinate (auto-animated in update) - linearDet = new MaterialProgressIndicator(20, 172, LINEAR, 300); - add(linearDet); - sectionLabel("determinate (auto-fills)", 332, 168); - - // Linear indeterminate - var linearInd = new MaterialProgressIndicator(20, 204, LINEAR, 300); - linearInd.indeterminate = true; - add(linearInd); - sectionLabel("indeterminate", 332, 200); - - sectionLabel("Progress Circular", 20, 234); - - // Circular determinate (auto-animated in update) - circularDet = new MaterialProgressIndicator(20, 254, CIRCULAR); - add(circularDet); - sectionLabel("determinate", 82, 274); - - // Circular indeterminate - var circularInd = new MaterialProgressIndicator(175, 254, CIRCULAR); - circularInd.indeterminate = true; - add(circularInd); - sectionLabel("indeterminate", 237, 274); - - sectionLabel("Loading Indicator (M3)", 20, 318); - - // Default loading indicator (48 px, no container) - var li1 = new MaterialLoadingIndicator(20, 340); - add(li1); - sectionLabel("default", 84, 366); - - // With container background - var li2 = new MaterialLoadingIndicator(175, 340, 48, true); - add(li2); - sectionLabel("with container", 239, 366); - - // Larger size (64 px) - var li3 = new MaterialLoadingIndicator(370, 332, 64); - add(li3); - sectionLabel("64 dp", 448, 366); - } - - override function update(elapsed:Float):Void - { - super.update(elapsed); - - // Animate determinate bars - progressTimer += elapsed * 0.3; - if (progressTimer > 1) progressTimer = 0; - if (linearDet != null) linearDet.value = progressTimer; - if (circularDet != null) circularDet.value = progressTimer; - - if (controls.UI_LEFT_P || FlxG.keys.justPressed.LEFT) - FlxG.switchState(new MD3Test1State()); - if (controls.UI_RIGHT_P || FlxG.keys.justPressed.RIGHT) - FlxG.switchState(new MD3Test3State()); - if (controls.BACK) - FlxG.switchState(new funkin.ui.title.TitleState()); - } -} diff --git a/source/funkin/ui/debug/MD3Test3State.hx b/source/funkin/ui/debug/MD3Test3State.hx deleted file mode 100644 index 9c54e9d2338..00000000000 --- a/source/funkin/ui/debug/MD3Test3State.hx +++ /dev/null @@ -1,118 +0,0 @@ -package funkin.ui.debug; - -import flixel.FlxG; -import flixel.FlxSprite; -import flixel.text.FlxText; -import flixel.util.FlxColor; -import funkin.ui.MusicBeatState; -import funkin.ui.debug.MD3TestState; -import funkin.ui.debug.MD3Test2State; -import funkin.ui.debug.MD3Test4State; -import funkin.ui.components.md3.*; - -/** - * MD3 component debug viewer — Page 4/4: Dialog, Menu, Snackbar, Tooltip. - * Navigate with LEFT/RIGHT arrow keys. Press BACK/ESC to exit. - */ -class MD3Test3State extends MusicBeatState -{ - static inline var PAGE_INDEX:Int = 3; - static inline var TOTAL_PAGES:Int = 7; - - var snackbar:MaterialSnackbar; - var dialog:MaterialDialog; - - override function create():Void - { - super.create(); - Cursor.show(); - - var bg = new FlxSprite(); - bg.makeGraphic(FlxG.width, FlxG.height, 0xFFFEF7FF); - add(bg); - - buildContent(); - - var pageLabel = new FlxText(0, FlxG.height - 28, FlxG.width, - '← → to navigate Page ${PAGE_INDEX + 1} / $TOTAL_PAGES ESC to exit', 14); - pageLabel.setFormat(Paths.font("phantom.ttf"), 14, 0xFF49454F, CENTER); - add(pageLabel); - - // Dialog renders above content but below snackbar - if (dialog != null) - add(dialog); - - // Snackbar always on top - snackbar = new MaterialSnackbar(340); - add(snackbar); - - addTouchPad('LEFT_RIGHT', 'B'); - } - - function sectionLabel(text:String, lx:Float, ly:Float):Void - { - var lbl = new FlxText(lx, ly, 0, text, 12); - lbl.setFormat(Paths.font("phantom.ttf"), 12, 0xFF79747E, LEFT); - add(lbl); - } - - function buildContent():Void - { - // ---- Dialog ---- - sectionLabel("Dialog", 20, 12); - - dialog = new MaterialDialog( - "Delete song?", - "This will permanently remove the song and all its assets. This action cannot be undone.", - "Delete", "Cancel", - function() { snackbar.show("Song deleted!", 3); }, - function() { snackbar.show("Cancelled.", 2); } - ); - - var dlgBtn = new MaterialButton(20, 34, "Open Dialog", FILLED, 160, function() { dialog.open(); }); - add(dlgBtn); - - // ---- Menu ---- - sectionLabel("Menu (Dropdown)", 20, 96); - - var menuBtn = new MaterialButton(20, 116, "Open Menu ▼", OUTLINED, 160, null); - var menu = new MaterialMenu(20, 160, ["New song", "Import chart", "Export", "Delete", "Properties"], 200, - function(i, item) { snackbar.show('Menu: "$item"', 2); }); - menuBtn.onClick = function() { menu.toggle(); }; - add(menuBtn); - add(menu); - - // ---- Snackbar ---- - sectionLabel("Snackbar", 260, 12); - - add(new MaterialButton(260, 34, "Show snackbar", FILLED, 170, function() - { - snackbar.show("Hello from snackbar!", 4, "UNDO", function() { snackbar.show("Undo pressed!", 2); }); - })); - add(new MaterialButton(260, 84, "Persistent snack", OUTLINED, 170, function() - { - snackbar.show("Persistent — no auto-hide.", 0, "OK", function() {}); - })); - - // ---- Tooltip ---- - sectionLabel("Tooltip (hover button)", 20, 252); - - var tipBtn = new MaterialButton(20, 272, "Hover me", OUTLINED, 140); - var tooltip = new MaterialTooltip("This is a tooltip!\nHover to trigger."); - tooltip.attachTo(20, 272, 140, 40); - add(tipBtn); - add(tooltip); - } - - override function update(elapsed:Float):Void - { - super.update(elapsed); - - if (controls.UI_LEFT_P || FlxG.keys.justPressed.LEFT) - FlxG.switchState(new MD3Test2State()); - if (controls.UI_RIGHT_P || FlxG.keys.justPressed.RIGHT) - FlxG.switchState(new MD3Test4State()); - if (controls.BACK) - FlxG.switchState(new funkin.ui.title.TitleState()); - } -} diff --git a/source/funkin/ui/debug/MD3Test4State.hx b/source/funkin/ui/debug/MD3Test4State.hx deleted file mode 100644 index 7baa71b5bd8..00000000000 --- a/source/funkin/ui/debug/MD3Test4State.hx +++ /dev/null @@ -1,131 +0,0 @@ -package funkin.ui.debug; - -import flixel.FlxG; -import flixel.FlxSprite; -import flixel.text.FlxText; -import funkin.ui.MusicBeatState; -import funkin.ui.debug.MD3Test3State; -import funkin.ui.debug.MD3Test5State; -import funkin.ui.components.md3.*; - -/** - * MD3 component debug viewer — Page 5/6: Checkbox, Switch, Radio Button. - * Navigate with LEFT/RIGHT arrow keys. Press BACK/ESC to exit. - */ -class MD3Test4State extends MusicBeatState -{ - static inline var PAGE_INDEX:Int = 4; - static inline var TOTAL_PAGES:Int = 7; - - var snackbar:MaterialSnackbar; - - override function create():Void - { - super.create(); - Cursor.show(); - - var bg = new FlxSprite(); - bg.makeGraphic(FlxG.width, FlxG.height, 0xFFFEF7FF); - add(bg); - - // Snackbar must exist before buildContent() so callbacks don't null-crash - snackbar = new MaterialSnackbar(340); - - buildContent(); - - var pageLabel = new FlxText(0, FlxG.height - 28, FlxG.width, - '← → to navigate Page ${PAGE_INDEX + 1} / $TOTAL_PAGES ESC to exit', 14); - pageLabel.setFormat(Paths.font("phantom.ttf"), 14, 0xFF49454F, CENTER); - add(pageLabel); - - add(snackbar); - - addTouchPad('LEFT_RIGHT', 'B'); - } - - function sectionLabel(text:String, lx:Float, ly:Float):Void - { - var lbl = new FlxText(lx, ly, 0, text, 12); - lbl.setFormat(Paths.font("phantom.ttf"), 12, 0xFF79747E, LEFT); - add(lbl); - } - - function buildContent():Void - { - // ---- CHECKBOX ---- - sectionLabel("Checkbox", 20, 12); - - var cb1 = new MaterialCheckbox(20, 34, "Unchecked", false, function(v) { snackbar.show('Checkbox 1: $v', 2); }); - add(cb1); - - var cb2 = new MaterialCheckbox(20, 80, "Checked", true, function(v) { snackbar.show('Checkbox 2: $v', 2); }); - add(cb2); - - var cb3 = new MaterialCheckbox(20, 126, "Disabled Off"); - cb3.enabled = false; - add(cb3); - - var cb4 = new MaterialCheckbox(20, 172, "Disabled On", true); - cb4.enabled = false; - add(cb4); - - // ---- SWITCH ---- - sectionLabel("Switch", 260, 12); - - var sw1 = new MaterialSwitch(260, 34, false); - sw1.onChange = function(v) { snackbar.show('Switch A: ${v ? "On" : "Off"}', 2); }; - add(sw1); - sectionLabel("Off", 322, 42); - - var sw2 = new MaterialSwitch(260, 80, true); - sw2.onChange = function(v) { snackbar.show('Switch B: ${v ? "On" : "Off"}', 2); }; - add(sw2); - sectionLabel("On", 322, 88); - - var sw3 = new MaterialSwitch(260, 126, false); - sw3.enabled = false; - add(sw3); - sectionLabel("Disabled", 322, 134); - - // ---- RADIO BUTTON ---- - sectionLabel("Radio Button (Group 1)", 20, 224); - - var rb1 = new MaterialRadioButton(20, 248, "Option A", "A", "p5group1", true, - function(v) { snackbar.show('Radio selected: $v', 2); }); - add(rb1); - - var rb2 = new MaterialRadioButton(20, 294, "Option B", "B", "p5group1", false, - function(v) { snackbar.show('Radio selected: $v', 2); }); - add(rb2); - - var rb3 = new MaterialRadioButton(20, 340, "Option C", "C", "p5group1", false, - function(v) { snackbar.show('Radio selected: $v', 2); }); - add(rb3); - - sectionLabel("Radio Button (Group 2)", 260, 224); - - var rb4 = new MaterialRadioButton(260, 248, "Yes", "yes", "p5group2", true, - function(v) { snackbar.show('Answer: $v', 2); }); - add(rb4); - - var rb5 = new MaterialRadioButton(260, 294, "No", "no", "p5group2", false, - function(v) { snackbar.show('Answer: $v', 2); }); - add(rb5); - - var rb6 = new MaterialRadioButton(260, 340, "Maybe", "maybe", "p5group2", false, - function(v) { snackbar.show('Answer: $v', 2); }); - add(rb6); - } - - override function update(elapsed:Float):Void - { - super.update(elapsed); - - if (controls.UI_LEFT_P || FlxG.keys.justPressed.LEFT) - FlxG.switchState(new MD3Test3State()); - if (controls.UI_RIGHT_P || FlxG.keys.justPressed.RIGHT) - FlxG.switchState(new MD3Test5State()); - if (controls.BACK) - FlxG.switchState(new funkin.ui.title.TitleState()); - } -} diff --git a/source/funkin/ui/debug/MD3Test5State.hx b/source/funkin/ui/debug/MD3Test5State.hx deleted file mode 100644 index fc744c98c52..00000000000 --- a/source/funkin/ui/debug/MD3Test5State.hx +++ /dev/null @@ -1,120 +0,0 @@ -package funkin.ui.debug; - -import flixel.FlxG; -import flixel.FlxSprite; -import flixel.text.FlxText; -import funkin.ui.MusicBeatState; -import funkin.ui.debug.MD3Test4State; -import funkin.ui.debug.MD3Test6State; -import funkin.ui.debug.MD3TestState; -import funkin.ui.components.md3.*; - -/** - * MD3 component debug viewer — Page 6/6: Slider, Outlined TextField, Filled TextField. - * Navigate with LEFT/RIGHT arrow keys. Press BACK/ESC to exit. - */ -class MD3Test5State extends MusicBeatState -{ - static inline var PAGE_INDEX:Int = 5; - static inline var TOTAL_PAGES:Int = 7; - - var snackbar:MaterialSnackbar; - - // Text fields kept as class vars so update() can check focused state - var tf1:MaterialTextField; - var tf2:MaterialTextField; - var ff1:FilledTextField; - var ff2:FilledTextField; - - override function create():Void - { - super.create(); - Cursor.show(); - - var bg = new FlxSprite(); - bg.makeGraphic(FlxG.width, FlxG.height, 0xFFFEF7FF); - add(bg); - - // Snackbar must exist before buildContent() so callbacks don't null-crash - snackbar = new MaterialSnackbar(340); - - buildContent(); - - var pageLabel = new FlxText(0, FlxG.height - 28, FlxG.width, - '← → to navigate Page ${PAGE_INDEX + 1} / $TOTAL_PAGES ESC to exit', 14); - pageLabel.setFormat(Paths.font("phantom.ttf"), 14, 0xFF49454F, CENTER); - add(pageLabel); - - add(snackbar); - - addTouchPad('LEFT_RIGHT', 'B'); - } - - function sectionLabel(text:String, lx:Float, ly:Float):Void - { - var lbl = new FlxText(lx, ly, 0, text, 12); - lbl.setFormat(Paths.font("phantom.ttf"), 12, 0xFF79747E, LEFT); - add(lbl); - } - - function buildContent():Void - { - // ---- SLIDER ---- - sectionLabel("Slider (continuous 0 – 1)", 20, 12); - - var slider1 = new MaterialSlider(20, 40, 280, 0.5, 0, 1); - slider1.onChange = function(v) { snackbar.show('Slider: ${Math.round(v * 100)}%', 1); }; - add(slider1); - - sectionLabel("Slider (integer 0 – 100)", 20, 96); - - var slider2 = new MaterialSlider(20, 124, 280, 50, 0, 100); - slider2.onChange = function(v) { snackbar.show('Volume: ${Std.int(v)}', 1); }; - add(slider2); - - sectionLabel("Slider (disabled)", 20, 180); - - var slider3 = new MaterialSlider(20, 208, 280, 0.7, 0, 1); - slider3.enabled = false; - add(slider3); - - // ---- OUTLINED TEXT FIELD ---- - sectionLabel("Outlined Text Field", 360, 12); - - tf1 = new MaterialTextField(360, 36, 220, "Username"); - add(tf1); - - tf2 = new MaterialTextField(360, 116, 220, "Email"); - add(tf2); - - // ---- FILLED TEXT FIELD ---- - sectionLabel("Filled Text Field", 360, 196); - - ff1 = new FilledTextField(360, 220, 220, "Search"); - add(ff1); - - ff2 = new FilledTextField(360, 300, 220, "Notes"); - add(ff2); - } - - override function update(elapsed:Float):Void - { - super.update(elapsed); - - // Block navigation while any text field is focused - var anyFocused:Bool = (tf1 != null && tf1.focused) - || (tf2 != null && tf2.focused) - || (ff1 != null && ff1.focused) - || (ff2 != null && ff2.focused); - - if (!anyFocused) - { - if (controls.UI_LEFT_P || FlxG.keys.justPressed.LEFT) - FlxG.switchState(new MD3Test4State()); - if (controls.UI_RIGHT_P || FlxG.keys.justPressed.RIGHT) - FlxG.switchState(new MD3Test6State()); - if (controls.BACK) - FlxG.switchState(new funkin.ui.title.TitleState()); - } - } -} diff --git a/source/funkin/ui/debug/MD3Test6State.hx b/source/funkin/ui/debug/MD3Test6State.hx deleted file mode 100644 index 5436d53e5a7..00000000000 --- a/source/funkin/ui/debug/MD3Test6State.hx +++ /dev/null @@ -1,250 +0,0 @@ -package funkin.ui.debug; - -import flixel.FlxG; -import flixel.FlxSprite; -import flixel.text.FlxText; -import flixel.util.FlxColor; -import funkin.ui.MusicBeatState; -import funkin.ui.debug.MD3Test5State; -import funkin.ui.debug.MD3Test7State; -import funkin.ui.components.md3.*; -import funkin.ui.components.md3.MaterialButton.ButtonType; - -/** - * MD3 component debug viewer — Page 7/7: Live accent color theme switcher. - * Tap any accent swatch to regenerate the full M3 palette; all components update instantly. - * Navigate with LEFT/RIGHT arrow keys. Press BACK/ESC to exit. - */ -class MD3Test6State extends MusicBeatState -{ - static inline var PAGE_INDEX:Int = 6; - static inline var TOTAL_PAGES:Int = 8; - - // Predefined accent colors from MD3Theme - static var ACCENT_NAMES:Array = ["Purple", "Teal", "Red", "Green", "Amber", "Indigo", "Pink"]; - static var ACCENT_COLORS:Array = [ - 0xFF6750A4, // Purple - 0xFF006B5F, // Teal - 0xFFB3261E, // Red - 0xFF146C2E, // Green - 0xFFAA8000, // Amber - 0xFF3F51B5, // Indigo - 0xFF7D3255 // Pink - ]; - - // Palette row labels and role accessors - static var ROLE_LABELS:Array = [ - "Primary", "On Primary", "Primary Cont.", "On P.Cont.", - "Secondary", "On Secondary", "Sec. Cont.", "On Sec.Cont.", - "Tertiary", "On Tertiary", "Tert. Cont.", "On T.Cont.", - "Surface", "On Surface", "Surf. Var.", "On S.Var.", - "Outline", "Outline Var.", "Inv. Surface", "Inv. Primary" - ]; - - // Live demo components that respond to theme changes - var demoButton:MaterialButton; - var demoSwitch:MaterialSwitch; - var demoChip:MaterialChip; - var demoFAB:MaterialFAB; - var demoProgress:MaterialProgressIndicator; - - // Palette swatches (updated on theme change) - var swatches:Array = []; - var swatchLabels:Array = []; - - // Active accent button highlight - var accentHighlight:FlxSprite; - var accentButtons:Array = []; - var activeAccentIndex:Int = 0; - - override function create():Void - { - super.create(); - Cursor.show(); - - var bg = new FlxSprite(); - bg.makeGraphic(FlxG.width, FlxG.height, 0xFFFEF7FF); - add(bg); - - buildAccentPicker(); - buildPaletteGrid(); - buildLiveDemos(); - - var pageLabel = new FlxText(0, FlxG.height - 28, FlxG.width, - '← → to navigate Page ${PAGE_INDEX + 1} / $TOTAL_PAGES ESC to exit', 14); - pageLabel.setFormat(Paths.font("phantom.ttf"), 14, 0xFF49454F, CENTER); - add(pageLabel); - - addTouchPad('LEFT_RIGHT', 'B'); - } - - function sectionLabel(text:String, lx:Float, ly:Float):Void - { - var lbl = new FlxText(lx, ly, 0, text, 12); - lbl.setFormat(Paths.font("phantom.ttf"), 12, 0xFF79747E, LEFT); - add(lbl); - } - - function buildAccentPicker():Void - { - sectionLabel("ACCENT COLORS — tap to change theme", 16, 10); - - // Highlight box behind active swatch - accentHighlight = new FlxSprite(); - accentHighlight.makeGraphic(46, 46, 0xFF000000); - add(accentHighlight); - - var startX:Float = 16; - var startY:Float = 30; - var gap:Float = 54; - - for (i in 0...ACCENT_COLORS.length) - { - var swatch = new FlxSprite(startX + i * gap, startY); - swatch.makeGraphic(42, 42, ACCENT_COLORS[i]); - add(swatch); - - var lbl = new FlxText(startX + i * gap, startY + 44, 48, ACCENT_NAMES[i], 9); - lbl.setFormat(Paths.font("phantom.ttf"), 9, 0xFF49454F, CENTER); - add(lbl); - - accentButtons.push(swatch); - } - - // Position highlight on default (index 0) - updateHighlight(0); - - // A hint label - var hintLbl = new FlxText(startX + ACCENT_COLORS.length * gap + 8, startY + 8, 180, - "Changing accent regenerates\nthe full M3 palette.", 11); - hintLbl.setFormat(Paths.font("phantom.ttf"), 11, 0xFF79747E, LEFT); - add(hintLbl); - } - - function buildPaletteGrid():Void - { - sectionLabel("COLOR PALETTE", 16, 102); - - var cols:Int = 10; - var sW:Int = 62; - var sH:Int = 32; - var gapX:Int = 2; - var gapY:Int = 22; - var startX:Float = 16; - var startY:Float = 120; - - for (i in 0...ROLE_LABELS.length) - { - var col = i % cols; - var row = Std.int(i / cols); - var sx = startX + col * (sW + gapX); - var sy = startY + row * (sH + gapY); - - var swatch = new FlxSprite(sx, sy); - swatch.makeGraphic(sW, sH, FlxColor.WHITE); - add(swatch); - swatches.push(swatch); - - var lbl = new FlxText(sx, sy + sH + 2, sW, ROLE_LABELS[i], 9); - lbl.setFormat(Paths.font("phantom.ttf"), 9, 0xFF49454F, CENTER); - add(lbl); - swatchLabels.push(lbl); - } - - refreshPaletteSwatches(); - MD3Theme.addListener(refreshPaletteSwatches); - } - - function buildLiveDemos():Void - { - sectionLabel("LIVE DEMO (updates with theme)", 16, 228); - - demoButton = new MaterialButton(16, 250, "Filled Button", FILLED, 140); - add(demoButton); - - demoSwitch = new MaterialSwitch(220, 258, true); - add(demoSwitch); - - demoChip = new MaterialChip(280, 252, "Filter Chip", FILTER, true); - add(demoChip); - - demoFAB = new MaterialFAB(450, 244, REGULAR, "", null); - add(demoFAB); - - demoProgress = new MaterialProgressIndicator(16, 320, LINEAR, 380); - demoProgress.value = 0.65; - add(demoProgress); - - var circProg = new MaterialProgressIndicator(420, 296, CIRCULAR); - circProg.value = 0.72; - add(circProg); - } - - function refreshPaletteSwatches():Void - { - var roles:Array = [ - MD3Theme.primary, MD3Theme.onPrimary, MD3Theme.primaryContainer, MD3Theme.onPrimaryContainer, - MD3Theme.secondary, MD3Theme.onSecondary, MD3Theme.secondaryContainer, MD3Theme.onSecondaryContainer, - MD3Theme.tertiary, MD3Theme.onTertiary, MD3Theme.tertiaryContainer, MD3Theme.onTertiaryContainer, - MD3Theme.surface, MD3Theme.onSurface, MD3Theme.surfaceVariant, MD3Theme.onSurfaceVariant, - MD3Theme.outline, MD3Theme.outlineVariant, MD3Theme.inverseSurface, MD3Theme.inversePrimary - ]; - - for (i in 0...swatches.length) - { - if (i < roles.length) - swatches[i].color = roles[i]; - } - } - - function applyAccent(index:Int):Void - { - activeAccentIndex = index; - MD3Theme.setAccent(ACCENT_COLORS[index]); - updateHighlight(index); - refreshPaletteSwatches(); - } - - function updateHighlight(index:Int):Void - { - if (accentButtons.length == 0) return; - var target = accentButtons[index]; - accentHighlight.x = target.x - 2; - accentHighlight.y = target.y - 2; - } - - override function update(elapsed:Float):Void - { - super.update(elapsed); - - #if FLX_MOUSE - if (FlxG.mouse.justPressed) - { - var mx = FlxG.mouse.x; - var my = FlxG.mouse.y; - for (i in 0...accentButtons.length) - { - var btn = accentButtons[i]; - if (mx >= btn.x && mx <= btn.x + btn.width && my >= btn.y && my <= btn.y + btn.height) - { - applyAccent(i); - break; - } - } - } - #end - - if (controls.UI_LEFT_P || FlxG.keys.justPressed.LEFT) - FlxG.switchState(new MD3Test5State()); - if (controls.UI_RIGHT_P || FlxG.keys.justPressed.RIGHT) - FlxG.switchState(new MD3Test7State()); - if (controls.BACK) - FlxG.switchState(new funkin.ui.title.TitleState()); - } - - override function destroy():Void - { - MD3Theme.removeListener(refreshPaletteSwatches); - super.destroy(); - } -} diff --git a/source/funkin/ui/debug/MD3Test7State.hx b/source/funkin/ui/debug/MD3Test7State.hx deleted file mode 100644 index 04a863cb769..00000000000 --- a/source/funkin/ui/debug/MD3Test7State.hx +++ /dev/null @@ -1,234 +0,0 @@ -package funkin.ui.debug; - -import flixel.FlxG; -import flixel.FlxSprite; -import flixel.text.FlxText; -import flixel.util.FlxColor; -import funkin.ui.MusicBeatState; -import funkin.ui.debug.MD3Test6State; -import funkin.ui.debug.MD3TestState; -import funkin.ui.components.md3.*; -import funkin.ui.components.md3.MaterialButton.ButtonType; -import funkin.ui.components.md3.MaterialChip.ChipType; - -/** - * MD3 component debug viewer — Page 8/8: MaterialBox demo. - * Shows multiple MaterialBox panels with different configurations: - * Left box — settings panel with slider/switch/checkbox inside - * Right box — info panel (read-only), demonstrating resize() + close - * Bottom — tiny box demonstrating canMinimize (double-click title) - * Navigate with LEFT/RIGHT arrow keys. Press BACK/ESC to exit. - */ -class MD3Test7State extends MusicBeatState -{ - static inline var PAGE_INDEX:Int = 7; - static inline var TOTAL_PAGES:Int = 8; - - var settingsBox:MaterialBox; - var infoBox:MaterialBox; - var miniBox:MaterialBox; - - // Controls inside settingsBox - var slider:MaterialSlider; - var toggle:MaterialSwitch; - var checkbox:MaterialCheckbox; - var statusText:FlxText; - - // Log inside infoBox - var logText:FlxText; - var logLines:Array = []; - - override function create():Void - { - super.create(); - Cursor.show(); - - // Background - var bg = new FlxSprite(); - bg.makeGraphic(FlxG.width, FlxG.height, MD3Theme.background); - add(bg); - - // ---------------------------------------------------------------- - // Settings box (left) — has interactive controls inside - // ---------------------------------------------------------------- - settingsBox = new MaterialBox(24, 40, 300, 260, "Settings"); - settingsBox.canMinimize = true; - - // Volume slider - var volLabel = new FlxText(10, 12, 220, "Volume", 12); - volLabel.setFormat(Paths.font("phantom.ttf"), 12, MD3Theme.onSurfaceVariant, LEFT); - settingsBox.content.add(volLabel); - - slider = new MaterialSlider(10, 30, 260, 70, 0, 100); - settingsBox.content.add(slider); - - // Fullscreen toggle - var fsLabel = new FlxText(10, 72, 180, "Fullscreen", 12); - fsLabel.setFormat(Paths.font("phantom.ttf"), 12, MD3Theme.onSurface, LEFT); - settingsBox.content.add(fsLabel); - - toggle = new MaterialSwitch(236, 66, false); - settingsBox.content.add(toggle); - - // Anti-aliasing checkbox - checkbox = new MaterialCheckbox(10, 106, "Anti-aliasing", true, function(v) { log('AA: $v'); }); - settingsBox.content.add(checkbox); - - // Apply button - var applyBtn = new MaterialButton(10, 150, "Apply", FILLED, 120, function() - { - log('Volume set to ${Std.int(slider.value)}'); - }); - settingsBox.content.add(applyBtn); - - // Reset button (tonal) - var resetBtn = new MaterialButton(140, 150, "Reset", OUTLINED, 110, function() - { - slider.value = 70; - log("Settings reset"); - }); - settingsBox.content.add(resetBtn); - - // Live status text at bottom of content - statusText = new FlxText(10, 192, 280, "Double-click title bar to minimize", 11); - statusText.setFormat(Paths.font("phantom.ttf"), 11, MD3Theme.onSurfaceVariant, LEFT); - settingsBox.content.add(statusText); - - add(settingsBox); - - // ---------------------------------------------------------------- - // Info / log box (right) — shows a scrolling log, has close button - // ---------------------------------------------------------------- - infoBox = new MaterialBox(340, 40, 260, 200, "Event Log"); - infoBox.canMinimize = true; - infoBox.onClose = function() - { - infoBox.visible = false; - log("[info box closed]"); - }; - - logText = new FlxText(8, 8, 240, "", 11); - logText.setFormat(Paths.font("phantom.ttf"), 11, MD3Theme.onSurface, LEFT); - infoBox.content.add(logText); - - add(infoBox); - - // ---------------------------------------------------------------- - // Mini box (bottom center) — very small, minimizable only - // ---------------------------------------------------------------- - miniBox = new MaterialBox(220, 330, 200, 150, "Mini Panel"); - miniBox.canMinimize = true; - - var miniChip = new MaterialChip("Chip A", ASSIST, false, null); - miniChip.setPosition(8, 10); - miniBox.content.add(miniChip); - - var miniChip2 = new MaterialChip("Chip B", FILTER, false, null); - miniChip2.setPosition(90, 10); - miniBox.content.add(miniChip2); - - var miniBtn = new MaterialButton(8, 55, "Action", TEXT, 160, function() - { - log("Mini panel action"); - }); - miniBox.content.add(miniBtn); - - var miniHint = new FlxText(8, 88, 180, "Double-click title to collapse", 10); - miniHint.setFormat(Paths.font("phantom.ttf"), 10, MD3Theme.onSurfaceVariant, LEFT); - miniBox.content.add(miniHint); - - add(miniBox); - - // ---------------------------------------------------------------- - // Accent color buttons (top-right strip) - // ---------------------------------------------------------------- - buildAccentStrip(); - - // ---------------------------------------------------------------- - // Page footer - // ---------------------------------------------------------------- - var footer = new FlxText(0, FlxG.height - 28, FlxG.width, - '← → to navigate Page ${PAGE_INDEX + 1} / $TOTAL_PAGES ESC to exit', 13); - footer.setFormat(Paths.font("phantom.ttf"), 13, MD3Theme.onSurfaceVariant, CENTER); - add(footer); - - addTouchPad('LEFT_RIGHT', 'B'); - - log("MaterialBox demo loaded"); - log("Try dragging the title bars"); - } - - // ---------------------------------------------------------------- - // Compact accent strip (top-right) - // ---------------------------------------------------------------- - - static var ACCENT_STRIP:Array = [0xFF6750A4, 0xFF006B5F, 0xFFB3261E, 0xFF146C2E, 0xFFAA8000]; - var accentSwatches:Array = []; - - function buildAccentStrip():Void - { - var lbl = new FlxText(0, 6, FlxG.width - 10, "ACCENT →", 11); - lbl.setFormat(Paths.font("phantom.ttf"), 11, MD3Theme.onSurfaceVariant, RIGHT); - add(lbl); - - var sx = FlxG.width - 10 - ACCENT_STRIP.length * 30; - var sy:Float = 20; - for (i in 0...ACCENT_STRIP.length) - { - var swatch = new FlxSprite(sx + i * 30, sy); - swatch.makeGraphic(24, 24, ACCENT_STRIP[i]); - accentSwatches.push(swatch); - add(swatch); - } - } - - // ---------------------------------------------------------------- - // Log helper - // ---------------------------------------------------------------- - - function log(msg:String):Void - { - logLines.push(msg); - if (logLines.length > 10) logLines.shift(); - if (logText != null) logText.text = logLines.join("\n"); - } - - // ---------------------------------------------------------------- - // Update - // ---------------------------------------------------------------- - - override function update(elapsed:Float):Void - { - super.update(elapsed); - - // Sync status text with slider value - statusText.text = 'Volume: ${Std.int(slider.value)} AA: ${checkbox.checked} Fullscreen: ${toggle.checked}'; - - #if FLX_MOUSE - if (FlxG.mouse.justPressed) - { - var mx = FlxG.mouse.x; - var my = FlxG.mouse.y; - - for (i in 0...accentSwatches.length) - { - var s = accentSwatches[i]; - if (mx >= s.x && mx <= s.x + s.width && my >= s.y && my <= s.y + s.height) - { - MD3Theme.setAccent(ACCENT_STRIP[i]); - // Re-tint background to match new surface color - cast(members[0], FlxSprite).color = MD3Theme.background; - break; - } - } - } - #end - - if (controls.UI_LEFT_P || FlxG.keys.justPressed.LEFT) - FlxG.switchState(new MD3Test6State()); - if (controls.UI_RIGHT_P || FlxG.keys.justPressed.RIGHT) - FlxG.switchState(new MD3TestState()); - if (controls.BACK) - FlxG.switchState(new funkin.ui.title.TitleState()); - } -} diff --git a/source/funkin/ui/debug/MD3TestState.hx b/source/funkin/ui/debug/MD3TestState.hx deleted file mode 100644 index a21a1e3633b..00000000000 --- a/source/funkin/ui/debug/MD3TestState.hx +++ /dev/null @@ -1,100 +0,0 @@ -package funkin.ui.debug; - -import flixel.FlxG; -import flixel.FlxSprite; -import flixel.text.FlxText; -import flixel.util.FlxColor; -import funkin.ui.MusicBeatState; -import funkin.ui.debug.MD3Test1State; -import funkin.ui.debug.MD3Test7State; -import funkin.ui.components.md3.*; - -/** - * MD3 component debug viewer — Page 1/4: Buttons, Icon Buttons, FABs, Dividers. - * Navigate with LEFT/RIGHT arrow keys. Press BACK/ESC to exit. - */ -class MD3TestState extends MusicBeatState -{ - static inline var PAGE_INDEX:Int = 0; - static inline var TOTAL_PAGES:Int = 8; - - var snackbar:MaterialSnackbar; - - override function create():Void - { - super.create(); - Cursor.show(); - - var bg = new FlxSprite(); - bg.makeGraphic(FlxG.width, FlxG.height, 0xFFFEF7FF); - add(bg); - - buildContent(); - - var pageLabel = new FlxText(0, FlxG.height - 28, FlxG.width, - '← → to navigate Page ${PAGE_INDEX + 1} / $TOTAL_PAGES ESC to exit', 14); - pageLabel.setFormat(Paths.font("phantom.ttf"), 14, 0xFF49454F, CENTER); - add(pageLabel); - - snackbar = new MaterialSnackbar(340); - add(snackbar); - - addTouchPad('LEFT_RIGHT', 'B'); - } - - function sectionLabel(text:String, lx:Float, ly:Float):Void - { - var lbl = new FlxText(lx, ly, 0, text, 12); - lbl.setFormat(Paths.font("phantom.ttf"), 12, 0xFF79747E, LEFT); - add(lbl); - } - - function buildContent():Void - { - sectionLabel("Buttons", 20, 12); - - var btnFilled = new MaterialButton(20, 36, "Filled", FILLED, 120, function() { snackbar.show("Filled button clicked!", 3); }); - add(btnFilled); - var btnOutlined = new MaterialButton(155, 36, "Outlined", OUTLINED, 120, function() { snackbar.show("Outlined button clicked!", 3); }); - add(btnOutlined); - var btnText = new MaterialButton(290, 36, "Text", TEXT, 100, function() { snackbar.show("Text button clicked!", 3); }); - add(btnText); - var btnDisabled = new MaterialButton(405, 36, "Disabled", FILLED, 120); - btnDisabled.enabled = false; - add(btnDisabled); - - sectionLabel("Icon Buttons", 20, 96); - - add(new MaterialIconButton(20, 118, STANDARD, function() { snackbar.show("Icon: Standard", 2); })); - add(new MaterialIconButton(72, 118, FILLED, function() { snackbar.show("Icon: Filled", 2); })); - add(new MaterialIconButton(124, 118, FILLED_TONAL, function() { snackbar.show("Icon: Tonal", 2); })); - add(new MaterialIconButton(176, 118, OUTLINED, function() { snackbar.show("Icon: Outlined", 2); })); - - sectionLabel("FAB", 20, 178); - - add(new MaterialFAB(20, 200, SMALL, "", function() { snackbar.show("FAB Small", 2); })); - add(new MaterialFAB(72, 196, REGULAR, "", function() { snackbar.show("FAB Regular", 2); })); - add(new MaterialFAB(136, 186, LARGE, "", function() { snackbar.show("FAB Large", 2); })); - add(new MaterialFAB(256, 206, REGULAR, "New song", function() { snackbar.show("Extended FAB clicked", 2); })); - - sectionLabel("Dividers", 20, 300); - - add(new MaterialDivider(20, 320, 400, false)); - add(new MaterialDivider(20, 340, 400, false, 60, 60)); - sectionLabel("← inset", 430, 334); - add(new MaterialDivider(20, 355, 100, true)); - sectionLabel("↕ vertical", 32, 358); - } - - override function update(elapsed:Float):Void - { - super.update(elapsed); - - if (controls.UI_LEFT_P || FlxG.keys.justPressed.LEFT) - FlxG.switchState(new MD3Test7State()); - if (controls.UI_RIGHT_P || FlxG.keys.justPressed.RIGHT) - FlxG.switchState(new MD3Test1State()); - if (controls.BACK) - FlxG.switchState(new funkin.ui.title.TitleState()); - } -} diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index 215878e52e8..8efa162d47b 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -24,6 +24,8 @@ import funkin.ui.debug.charting.components.VSlice.VSliceMetadata; import funkin.ui.debug.charting.components.VSlice.VSliceChart; import funkin.ui.debug.charting.components.VSlice.VSliceNote; import funkin.ui.debug.charting.components.VSlice.PsychPackage; +import funkin.ui.debug.charting.components.CodenameEngine.CodenameChart; +import funkin.ui.debug.charting.components.CodenameEngine.CodenameMetaData; import funkin.ui.debug.charting.components.Prompt.BasePrompt; import funkin.ui.debug.charting.components.*; @@ -37,6 +39,8 @@ import funkin.play.HealthIcon; import funkin.play.notes.Note; import funkin.play.notes.StrumNote; +import funkin.ui.mainmenu.MainMenuState; + using DateTools; typedef UndoStruct = { @@ -659,7 +663,7 @@ class ChartEditorState extends MusicBeatState implements PsychUIEventHandler.Psy player2: 'dad', gfVersion: 'gf', stage: 'stage', - format: 'psych_v1' + format: 'psych_v2' }; Song.chartPath = null; loadChart(song); @@ -708,6 +712,7 @@ class ChartEditorState extends MusicBeatState implements PsychUIEventHandler.Psy gameOverRetryInputText.text = PlayState.SONG.gameOverEnd; noRGBCheckBox.checked = (PlayState.SONG.disableNoteRGB == true); + useModchartsCheckBox.checked = (PlayState.SONG.useModcharts == true); noteTextureInputText.text = PlayState.SONG.arrowSkin; noteSplashesInputText.text = PlayState.SONG.splashSkin; @@ -2753,6 +2758,7 @@ class ChartEditorState extends MusicBeatState implements PsychUIEventHandler.Psy var gameOverLoopInputText:PsychUIInputText; var gameOverRetryInputText:PsychUIInputText; var noRGBCheckBox:PsychUICheckBox; + var useModchartsCheckBox:PsychUICheckBox; var noteTextureInputText:PsychUIInputText; var noteSplashesInputText:PsychUIInputText; function addDataTab() @@ -2791,7 +2797,14 @@ class ChartEditorState extends MusicBeatState implements PsychUIEventHandler.Psy objY += 35; noRGBCheckBox = new PsychUICheckBox(objX, objY, 'Disable Note RGB', 100, updateNotesRGB); - + + objY += 24; + useModchartsCheckBox = new PsychUICheckBox(objX, objY, 'Use Modcharts', 100, function() + { + PlayState.SONG.useModcharts = useModchartsCheckBox.checked; + if (!useModchartsCheckBox.checked) Reflect.deleteField(PlayState.SONG, 'useModcharts'); + }); + objY += 40; noteTextureInputText = new PsychUIInputText(objX, objY, 120, ''); noteTextureInputText.unfocus = function() @@ -2841,6 +2854,7 @@ class ChartEditorState extends MusicBeatState implements PsychUIEventHandler.Psy tab_group.add(gameOverLoopInputText); tab_group.add(gameOverRetryInputText); tab_group.add(noRGBCheckBox); + tab_group.add(useModchartsCheckBox); tab_group.add(new FlxText(noteTextureInputText.x, noteTextureInputText.y - 15, 100, 'Note Texture:')); tab_group.add(new FlxText(noteSplashesInputText.x, noteSplashesInputText.y - 15, 120, 'Note Splashes Texture:')); @@ -4240,6 +4254,173 @@ class ChartEditorState extends MusicBeatState implements PsychUIEventHandler.Psy btn.text.alignment = LEFT; tab_group.add(btn); + btnY += 20; + var btn:PsychUIButton = new PsychUIButton(btnX, btnY, ' Codename to Psych...', function() + { + if(!fileDialog.completed) return; + upperBox.isMinimized = true; + upperBox.bg.visible = false; + + fileDialog.open('normal.json', 'Open a Codename Engine Chart file', function() + { + var chartData:Dynamic = null; + try { chartData = Json.parse(fileDialog.data); } catch(e:Exception) {} + + if(chartData == null || chartData.codenameChart == null || !chartData.codenameChart || chartData.strumLines == null) + { + showOutput('Error: File loaded is not a valid Codename Engine chart.', true); + return; + } + + // Extract the difficulty name from the filename (e.g. "normal.json" → "normal") + var chartFilePath:String = fileDialog.path.replace('\\', '/'); + var diffName:String = chartFilePath.substring(chartFilePath.lastIndexOf('/')+1, chartFilePath.lastIndexOf('.')); + if(diffName == null || diffName.length < 1) diffName = 'normal'; + + var chart:CodenameChart = cast chartData; + var needsMeta:Bool = (chart.meta == null || Reflect.field(chart.meta, 'bpm') == null); + + var doConversion = function(?extraMeta:CodenameMetaData) + { + try + { + var pack:PsychPackage = CodenameEngine.convertToPsych(chart, extraMeta, diffName); + if(pack.difficulties == null || !pack.difficulties.exists(diffName)) + { + showOutput('Error: Conversion failed.', true); + return; + } + + var chartSong:SwagSong = pack.difficulties.get(diffName); + var defaultDiff:String = Paths.formatToSongPath(Difficulty.getDefault()); + var diffPostfix:String = (diffName != defaultDiff) ? '-$diffName' : ''; + var outFileName:String = Paths.formatToSongPath(chartSong.song) + diffPostfix + '.json'; + + fileDialog.openDirectory('Save Converted Psych JSON', function() + { + var path:String = fileDialog.path.replace('\\', '/'); + if(!path.endsWith('/')) path += '/'; + + overwriteSavedSomething = false; + overwriteCheck(path + outFileName, outFileName, + PsychJsonPrinter.print(chartSong, ['sectionNotes', 'events']), + function() + { + if(pack.events != null) + { + overwriteCheck(path + 'events.json', 'events.json', + PsychJsonPrinter.print(pack.events, ['events']), + function() + { + if(overwriteSavedSomething) + showOutput('Files saved successfully to: ${fileDialog.path}!'); + }, true); + } + else if(overwriteSavedSomething) + showOutput('File saved successfully to: ${fileDialog.path}!'); + }, true); + }); + } + catch(e:Exception) + { + showOutput('Error: ${e.message}', true); + trace(e.stack); + } + }; + + if(needsMeta) + { + fileDialog.open('meta.json', 'Open the Codename Engine meta.json file', function() + { + var meta:CodenameMetaData = null; + try { meta = cast Json.parse(fileDialog.data); } catch(e:Exception) {} + doConversion(meta); + }); + } + else + { + doConversion(null); + } + }); + },btnWid); + btn.text.alignment = LEFT; + tab_group.add(btn); + + btnY += 20; + var btn:PsychUIButton = new PsychUIButton(btnX, btnY, ' NightmareVision to Psych...', function() + { + if(!fileDialog.completed) return; + upperBox.isMinimized = true; + upperBox.bg.visible = false; + + // Credits: Nightmare Vision by DuskieWhy + // https://github.com/DuskieWhy/NightmareVision + fileDialog.open('songname.json', 'Open a Nightmare Vision Chart file', function() + { + var filePath:String = fileDialog.path.replace('\\', '/'); + var fileName:String = filePath.substring(filePath.lastIndexOf('/')+1, filePath.lastIndexOf('.')); + + var loadedChart:SwagSong = null; + try + { + // NMV uses {"song": {...}} wrapping identical to Psych 0.x; + // Song.parseJSON handles unwrapping and format conversion automatically. + loadedChart = Song.parseJSON(fileDialog.data, fileName); + } + catch(e:Exception) + { + showOutput('Error parsing chart: ${e.message}', true); + return; + } + + if(loadedChart == null || !Reflect.hasField(loadedChart, 'song')) + { + showOutput('Error: File loaded is not a valid Nightmare Vision chart.', true); + return; + } + + // Warn about non-standard key counts + var keys:Int = Reflect.hasField(loadedChart, 'keys') ? cast Reflect.field(loadedChart, 'keys') : 4; + var lanes:Int = Reflect.hasField(loadedChart, 'lanes') ? cast Reflect.field(loadedChart, 'lanes') : 2; + if(keys != 4 || lanes != 2) + showOutput('Warning: chart uses $keys keys, $lanes lanes. Only 4K/2-lane charts convert correctly.'); + + Reflect.setField(loadedChart, 'format', 'psych_v1_convert'); + Reflect.setField(loadedChart, 'generatedBy', + 'Psych Engine v${MainMenuState.psychEngineVersion} - Chart Editor NMV Importer (https://github.com/DuskieWhy/NightmareVision)'); + + // Determine output file name from the song field + var defaultDiff:String = Paths.formatToSongPath(Difficulty.getDefault()); + var songFmt:String = Paths.formatToSongPath(loadedChart.song); + // Detect difficulty suffix in filename (e.g. "bopeebo-hard" → diff = "hard") + var detectedDiff:String = defaultDiff; + for (d in Difficulty.list) + { + var ds:String = Paths.formatToSongPath(d); + if(fileName.endsWith('-' + ds)) { detectedDiff = ds; break; } + } + var diffPostfix:String = (detectedDiff != defaultDiff) ? '-$detectedDiff' : ''; + var outFileName:String = songFmt + diffPostfix + '.json'; + + fileDialog.openDirectory('Save Converted Psych JSON', function() + { + var path:String = fileDialog.path.replace('\\', '/'); + if(!path.endsWith('/')) path += '/'; + + overwriteSavedSomething = false; + overwriteCheck(path + outFileName, outFileName, + PsychJsonPrinter.print(loadedChart, ['sectionNotes', 'events']), + function() + { + if(overwriteSavedSomething) + showOutput('File saved successfully to: ${fileDialog.path}!'); + }, true); + }); + }); + },btnWid); + btn.text.alignment = LEFT; + tab_group.add(btn); + btnY += 20; var btn:PsychUIButton = new PsychUIButton(btnX, btnY, ' V-Slice to Psych...', function() { @@ -4321,7 +4502,47 @@ class ChartEditorState extends MusicBeatState implements PsychUIEventHandler.Psy },btnWid); btn.text.alignment = LEFT; tab_group.add(btn); - + + btnY += 20; + var btn:PsychUIButton = new PsychUIButton(btnX, btnY, ' Load v2...', function() + { + if(!fileDialog.completed) return; + upperBox.isMinimized = true; + upperBox.bg.visible = false; + + fileDialog.open('song.json', 'Open a psych_v2 Chart file', function() + { + try + { + var raw:Dynamic = Json.parse(fileDialog.data); + if (raw == null || raw.format != 'psych_v2' || raw.notes == null) + { + showOutput('Error: File loaded is not a valid psych_v2 chart.', true); + return; + } + var loadedSong:SwagSong = Song.downgradeFromV2(raw); + var func:Void->Void = function() + { + loadChart(loadedSong); + reloadNotesDropdowns(); + prepareReload(); + showOutput('v2 chart loaded successfully!'); + }; + if (!ignoreProgressCheckBox.checked) + openSubState(new Prompt('Warning: Any unsaved progress will be lost', func)); + else + func(); + } + catch(e:Exception) + { + showOutput('Error: ${e.message}', true); + trace(e.stack); + } + }); + }, btnWid); + btn.text.alignment = LEFT; + tab_group.add(btn); + btnY += 20; var btn:PsychUIButton = new PsychUIButton(btnX, btnY, ' Update (Legacy)...', function() { @@ -4946,7 +5167,8 @@ class ChartEditorState extends MusicBeatState implements PsychUIEventHandler.Psy function saveChart(canQuickSave:Bool = true) { updateChartData(); - var chartData:String = PsychJsonPrinter.print(PlayState.SONG, ['sectionNotes', 'events']); + var v2:Dynamic = Song.upgradeToV2(PlayState.SONG); + var chartData:String = PsychJsonPrinter.print(v2, ['notes', 'events', 'bpmChanges', 'characters']); if(canQuickSave && Song.chartPath != null) { #if mobile diff --git a/source/funkin/ui/debug/charting/components/CodenameEngine.hx b/source/funkin/ui/debug/charting/components/CodenameEngine.hx new file mode 100644 index 00000000000..d299935c431 --- /dev/null +++ b/source/funkin/ui/debug/charting/components/CodenameEngine.hx @@ -0,0 +1,329 @@ +package funkin.ui.debug.charting.components; + +// Credits: Codename Engine by CodenameCrew +// https://github.com/CodenameCrew/CodenameEngine + +import funkin.data.song.Song; +import funkin.ui.debug.charting.components.VSlice.PsychPackage; +import funkin.ui.mainmenu.MainMenuState; +import flixel.util.FlxSort; + +/** Root Codename Engine chart data structure. */ +typedef CodenameChart = +{ + var strumLines:Array; + var events:Array; + var ?meta:CodenameMetaData; + var codenameChart:Bool; + var ?stage:String; + var scrollSpeed:Float; + var noteTypes:Array; + var ?chartVersion:String; +} + +/** Song/difficulty metadata stored in meta.json or embedded in the chart. */ +typedef CodenameMetaData = +{ + var name:String; + var ?bpm:Float; + var ?beatsPerMeasure:Float; + var ?stepsPerBeat:Int; + var ?needsVoices:Bool; + var ?displayName:String; +} + +/** A strum line (opponent / player / additional). */ +typedef CodenameStrumLine = +{ + var characters:Array; + /** 0 = OPPONENT, 1 = PLAYER, 2 = ADDITIONAL (GF / spectator). */ + var type:Int; + var notes:Array; + var position:String; + var ?scrollSpeed:Null; + var ?keyCount:Null; +} + +/** A single note inside a strum line. */ +typedef CodenameNote = +{ + /** Hit time in ms. */ + var time:Float; + /** Column index within the strum line (0-3 for a standard 4K lane). */ + var id:Int; + /** 1-based index into noteTypes; 0 = default note. */ + var type:Int; + /** Sustain / hold length in ms. */ + var sLen:Float; +} + +/** A chart event. */ +typedef CodenameEvent = +{ + var name:String; + var time:Float; + var params:Array; + var ?global:Bool; +} + +class CodenameEngine +{ + static inline final DEFAULT_BPM:Float = 100.0; + static inline final DEFAULT_BEATS_PER_MEASURE:Float = 4.0; + + /** + * Converts a Codename Engine chart (and optional separate meta) into a PsychPackage + * compatible with Psych Engine 1.0 / FNF Plus Engine. + * + * Credits: Codename Engine by CodenameCrew + * https://github.com/CodenameCrew/CodenameEngine + * + * @param chart Parsed Codename chart JSON. + * @param extraMeta Optional meta loaded from a separate meta.json file. + * Used only when the chart does not contain embedded meta. + * @param diffName Difficulty name used as the key in the returned package's difficulties map. + */ + public static function convertToPsych(chart:CodenameChart, ?extraMeta:CodenameMetaData, ?diffName:String = 'normal'):PsychPackage + { + var meta:CodenameMetaData = chart.meta != null ? chart.meta : extraMeta; + + var songName:String = 'converted'; + if (meta != null) + songName = (meta.displayName != null && meta.displayName.length > 0) ? meta.displayName : meta.name; + + var baseBpm:Float = (meta != null && meta.bpm != null && meta.bpm > 0) ? meta.bpm : DEFAULT_BPM; + var beatsPerMeasure:Float = (meta != null && meta.beatsPerMeasure != null && meta.beatsPerMeasure > 0) ? meta.beatsPerMeasure : DEFAULT_BEATS_PER_MEASURE; + + var noteTypes:Array = chart.noteTypes != null ? chart.noteTypes : []; + var strumLines:Array = chart.strumLines != null ? chart.strumLines : []; + + // Sort and classify events + var events:Array = chart.events != null ? chart.events.copy() : []; + events.sort(sortByTime); + + var bpmChanges:Array = events.filter(function(e) return e.name == 'BPM Change'); + var camChanges:Array = events.filter(function(e) return e.name == 'Camera Movement'); + // Exclude events handled implicitly (camera / BPM / alt anim) and global event files + var otherEvents:Array = events.filter(function(e) + return e.name != 'Camera Movement' + && e.name != 'BPM Change' + && e.name != 'Alt Animation Toggle' + && e.global != true + ); + + // Returns the active BPM at time t using BPM Change events + var getBpmAt = function(t:Float):Float + { + var bpm:Float = baseBpm; + for (change in bpmChanges) + { + if (change.time <= t + 1) + bpm = change.params[0]; + else + break; + } + return bpm; + }; + + // Find the time of the last note (including sustain length) + var lastNoteTime:Float = 0; + for (sl in strumLines) + { + if (sl.notes == null) continue; + for (note in sl.notes) + { + var endTime:Float = note.time + (note.sLen > 0 ? note.sLen : 0); + if (endTime > lastNoteTime) + lastNoteTime = endTime; + } + } + // Always generate at least one section + if (lastNoteTime <= 0) + lastNoteTime = (60000.0 / baseBpm) * beatsPerMeasure; + + // Build section start times using correct BPM at each boundary + var sectionTimes:Array = []; + var time:Float = 0; + while (time <= lastNoteTime + 1) + { + sectionTimes.push(time); + var bpm:Float = getBpmAt(time); + time += (60000.0 / bpm) * beatsPerMeasure; + } + + // Compute mustHitSection per section from Camera Movement events. + // Camera Movement params[0] = strumLine index; PLAYER type (1) → mustHitSection = true. + // This mirrors how FNFLegacyParser.__convertToSwagSections assigns mustHitSection. + var sectionMustHits:Array = []; + var lastMustHit:Bool = false; + var camIdx:Int = 0; + + for (i in 0...sectionTimes.length) + { + var sectionStart:Float = sectionTimes[i]; + var sectionEnd:Float = (i + 1 < sectionTimes.length) ? sectionTimes[i + 1] : Math.POSITIVE_INFINITY; + + while (camIdx < camChanges.length && camChanges[camIdx].time < sectionEnd) + { + var cam:CodenameEvent = camChanges[camIdx++]; + if (cam.time >= sectionStart) + { + var idx:Int = Std.int(cam.params[0]); + if (idx < strumLines.length) + lastMustHit = (strumLines[idx].type == 1); // PLAYER = 1 + } + } + sectionMustHits.push(lastMustHit); + } + + // Build SwagSection array + var swagSections:Array = []; + var lastBpm:Float = baseBpm; + + for (i in 0...sectionTimes.length) + { + var sec:SwagSection = emptySection(); + sec.mustHitSection = (i < sectionMustHits.length) ? sectionMustHits[i] : false; + + var bpm:Float = getBpmAt(sectionTimes[i]); + if (bpm != lastBpm) + { + sec.changeBPM = true; + sec.bpm = bpm; + lastBpm = bpm; + } + swagSections.push(sec); + } + + // Distribute notes from each strum line into the correct section and column. + // Note column mapping (inverse of FNFLegacyParser.parse logic): + // needsOffset = (isPlayer) XOR (mustHitSection) + // noteData = note.id + (needsOffset ? 4 : 0) + for (sl in strumLines) + { + if (sl.notes == null || sl.type > 1) continue; // Skip GF / spectator strum lines + + var isPlayer:Bool = (sl.type == 1); + + for (note in sl.notes) + { + // Locate the containing section + var secIdx:Int = sectionTimes.length - 1; + for (i in 0...sectionTimes.length - 1) + { + if (sectionTimes[i + 1] > note.time) + { + secIdx = i; + break; + } + } + if (secIdx < 0 || secIdx >= swagSections.length) continue; + + var mustHit:Bool = swagSections[secIdx].mustHitSection; + var needsOffset:Bool = isPlayer != mustHit; + var noteData:Int = note.id + (needsOffset ? 4 : 0); + + // Resolve note type string (noteTypes is 0-indexed; note.type is 1-based) + var noteTypeStr:String = ''; + if (note.type > 0 && note.type <= noteTypes.length) + { + var t:String = noteTypes[note.type - 1]; + if (t != null && t != 'Default Note') + noteTypeStr = t; + } + + var psychNote:Array = [note.time, noteData, note.sLen]; + if (noteTypeStr.length > 0) + psychNote.push(noteTypeStr); + + swagSections[secIdx].sectionNotes.push(psychNote); + } + } + + // Extract character names from strum lines + var player1:String = 'bf'; + var player2:String = 'dad'; + var gfVersion:String = 'gf'; + + for (sl in strumLines) + { + var chars:Array = sl.characters != null ? sl.characters : []; + switch (sl.type) + { + case 0: if (chars.length > 0) player2 = chars[0]; // OPPONENT + case 1: if (chars.length > 0) player1 = chars[0]; // PLAYER + case 2: if (chars.length > 0) gfVersion = chars[0]; // ADDITIONAL + } + } + + var generatedBy:String = 'Psych Engine v${MainMenuState.psychEngineVersion} - Chart Editor Codename Engine Importer (https://github.com/CodenameCrew/CodenameEngine)'; + + var swagSong:SwagSong = { + song: songName, + notes: swagSections, + events: [], + bpm: baseBpm, + needsVoices: (meta != null && meta.needsVoices != null) ? meta.needsVoices : true, + speed: chart.scrollSpeed > 0 ? chart.scrollSpeed : 1.0, + offset: 0, + player1: player1, + player2: player2, + gfVersion: gfVersion, + stage: (chart.stage != null && chart.stage.length > 0) ? chart.stage : 'stage', + format: 'psych_v1_convert' + }; + + Reflect.setField(swagSong, 'generatedBy', generatedBy); + + // Convert remaining events to Psych format: [time, [[name, val1, val2], ...]] + var fileEvents:Array = null; + if (otherEvents.length > 0) + { + // Group events at the same timestamp + var groupedTimes:Array = []; + var groupedMap:Map>> = []; + + for (event in otherEvents) + { + var key:String = Std.string(event.time); + var params:Array = event.params != null ? event.params : []; + var val1:String = params.length > 0 ? Std.string(params[0]) : ''; + var val2:String = params.length > 1 ? Std.string(params[1]) : ''; + var psychEvent:Array = [event.name, val1, val2]; + + if (!groupedMap.exists(key)) + { + groupedMap.set(key, []); + groupedTimes.push(event.time); + } + groupedMap.get(key).push(psychEvent); + } + + groupedTimes.sort(function(a, b) return a < b ? -1 : (a > b ? 1 : 0)); + + fileEvents = []; + for (t in groupedTimes) + fileEvents.push([t, groupedMap.get(Std.string(t))]); + } + + var difficulties:Map = []; + difficulties.set(diffName, swagSong); + + return { + difficulties: difficulties, + events: fileEvents != null ? {events: fileEvents, format: 'psych_v1_convert'} : null + }; + } + + static function emptySection():SwagSection + { + return { + sectionNotes: [], + sectionBeats: 4, + mustHitSection: false + }; + } + + static function sortByTime(a:CodenameEvent, b:CodenameEvent):Int + return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time); +} diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx index f5e22b553f7..24d6c3df453 100644 --- a/source/funkin/ui/freeplay/FreeplayState.hx +++ b/source/funkin/ui/freeplay/FreeplayState.hx @@ -7,17 +7,15 @@ import funkin.save.Highscore; import funkin.data.song.Song; import funkin.data.song.Song.SwagSong; import funkin.play.HealthIcon; -import funkin.ui.MusicPlayer; import funkin.ui.options.GameplayChangersSubstate; import funkin.play.substates.ResetScoreSubState; import funkin.ui.components.PsychUIInputText; import flixel.math.FlxMath; +import flixel.math.FlxPoint; import flixel.util.FlxDestroyUtil; import flixel.util.FlxTimer; import flixel.tweens.FlxTween; import flixel.math.FlxRect; -import openfl.utils.Assets; - #if MODS_ALLOWED import sys.FileSystem; #end @@ -55,6 +53,9 @@ class FreeplayState extends MusicBeatState { public static var viewingOpponentScores:Bool = false; var instPlaying:Int = -1; var previewTimer:FlxTimer = null; + var previewLoadTimer:FlxTimer = null; + var previewLoadToken:Int = 0; + static inline var PREVIEW_LOAD_DELAY:Float = 0.12; var _prevInstSongName:String = null; // Track previous inst to unload from cache var holdTime:Float = 0; var stopMusicPlay:Bool = false; @@ -103,6 +104,18 @@ class FreeplayState extends MusicBeatState { var _cachedVisibleIndices:Array = []; var _visCacheValid:Bool = false; var _lastShowingFavorites:Bool = false; + var _vizUpdateAccum:Float = 0.0; + var _vizTargetHeights:Array = []; + var _vizCurrentHeights:Array = []; + #if mobile + static inline var VIZ_UPDATE_INTERVAL:Float = 1 / 45; + #else + static inline var VIZ_UPDATE_INTERVAL:Float = 1 / 60; + #end + static inline var VIZ_MIN_H:Float = 2; + static inline var VIZ_SMOOTH_SPEED:Float = 18; + var _lastAppliedBgZoom:Float = -1; + var _lastAppliedAlbumZoom:Float = -1; var diffViewOffset:Float = 0; var lerpDiffViewOffset:Float = 0; @@ -116,6 +129,11 @@ class FreeplayState extends MusicBeatState { var blackOverlay:FlxSprite; var dinamic:FlxSprite; var album:FlxSprite; + var bgTransition:FlxSprite; + var bgFadeTween:FlxTween = null; + var currentBgAssetKey:String = 'menuDesat'; + var currentBgFolderKey:String = ''; + var currentAlbumAssetKey:String = 'albumRoll/example'; var uiprincipal:FlxSprite; var list:FlxSprite; var cardsGroup:FlxTypedGroup; @@ -139,16 +157,21 @@ class FreeplayState extends MusicBeatState { // Note density bar constants (small section, chart-data only) static inline var BAR_COUNT:Int = 24; - static inline var BAR_WIDTH:Int = 10; - static inline var BAR_STEP:Int = 12; // bar width + gap + static inline var BAR_WIDTH:Int = 8; + static inline var BAR_STEP:Int = 10; // bar width + gap static inline var BAR_START_X:Float = 417; static inline var BAR_BASELINE_Y:Float = 605; static inline var BAR_MIN_H:Int = 4; static inline var BAR_MAX_H:Int = 64; // Full-width bottom spectral visualizer bar constants - static inline var VIZ_BAR_COUNT:Int = 256; + #if mobile + static inline var VIZ_BAR_COUNT:Int = 96; + #else + static inline var VIZ_BAR_COUNT:Int = 160; + #end static inline var VIZ_BAR_MAX_H:Int = 240; + static inline var VIZ_BAR_FILL:Float = 0.62; var blurEffect:BlurEffect; //var diff:Array; @@ -207,10 +230,6 @@ class FreeplayState extends MusicBeatState { var bottomString:String; var bottomText:FlxText; - // Music player - var player:MusicPlayer; - - override public function create():Void { super.create(); @@ -234,11 +253,19 @@ class FreeplayState extends MusicBeatState { bg.antialiasing = ClientPrefs.data.antialiasing; add(bg); bg.screenCenter(); + + bgTransition = new FlxSprite().loadGraphic(Paths.image('menuDesat')); + bgTransition.antialiasing = ClientPrefs.data.antialiasing; + bgTransition.alpha = 0; + bgTransition.visible = false; + add(bgTransition); + bgTransition.screenCenter(); #if !mobile blurEffect = new BlurEffect(); blurEffect.strength = 5.0; bg.shader = blurEffect.shader; + bgTransition.shader = blurEffect.shader; #end //bgZoom = defaultBgZoom = 1; @@ -250,16 +277,19 @@ class FreeplayState extends MusicBeatState { // Driven exclusively by SpectralAnalyzer; note density bars are separate. vizBarsGroup = new FlxTypedGroup(); var vizBarW:Int = Std.int(FlxG.width / VIZ_BAR_COUNT); // 1280 / 64 = 20px per slot + var vizDrawW:Int = Std.int(Math.max(1, vizBarW * VIZ_BAR_FILL)); + var vizOffsetX:Float = (vizBarW - vizDrawW) * 0.5; for(i in 0...VIZ_BAR_COUNT) { var vbar:FlxSprite = new FlxSprite(); - vbar.makeGraphic(vizBarW - 1, VIZ_BAR_MAX_H, FlxColor.WHITE); - vbar.setGraphicSize(vizBarW - 1, 2); - vbar.updateHitbox(); - vbar.x = i * vizBarW; + vbar.makeGraphic(vizDrawW, VIZ_BAR_MAX_H, FlxColor.WHITE); + vbar.x = i * vizBarW + vizOffsetX; vbar.y = FlxG.height - 2; + vbar.scale.y = 2 / VIZ_BAR_MAX_H; vbar.alpha = 0.0; vbar.ID = i; vizBarsGroup.add(vbar); + _vizTargetHeights.push(VIZ_MIN_H); + _vizCurrentHeights.push(VIZ_MIN_H); } add(vizBarsGroup); @@ -645,9 +675,6 @@ class FreeplayState extends MusicBeatState { noFavoritesText.visible = false; add(noFavoritesText); - player = new MusicPlayer(this); - add(player); - Conductor.bpm = 102; // Start the visualizer with the freeplay menu music right away. @@ -660,8 +687,11 @@ class FreeplayState extends MusicBeatState { updateDynamicData(); #if mobile - addTouchPad('UP_DOWN', 'A_B_C_X_Y_Z'); + addTouchPad('LEFT_FULL', 'A_B_C_X_Y_Z'); addTouchPadCamera(); + touchScroll = new funkin.mobile.backend.TouchScroll(true); + difficultyScroll = new funkin.mobile.backend.TouchScroll(false); + funkin.mobile.backend.TouchUtil.setScrollHandler(touchScroll); if(touchPad != null) { touchPad.visible = true; touchPad.updateTrackedButtons(); @@ -732,6 +762,31 @@ class FreeplayState extends MusicBeatState { var leWeek:WeekData = WeekData.weeksLoaded.get(name); return (!leWeek.startUnlocked && leWeek.weekBefore.length > 0 && (!funkin.ui.story.StoryMenuState.weekCompleted.exists(leWeek.weekBefore) || !funkin.ui.story.StoryMenuState.weekCompleted.get(leWeek.weekBefore))); } + + function getVisibleSongIndices():Array { + var visibleIndices:Array = []; + if(showingFavorites) { + for(i in 0...songs.length) { + if(songs[i].isFavorite) { + visibleIndices.push(i); + } + } + } else { + for(i in 0...songs.length) { + visibleIndices.push(i); + } + } + return visibleIndices; + } + + function getCurrentVisibleSelectionIndex(visibleIndices:Array):Int { + for(vi in 0...visibleIndices.length) { + if(visibleIndices[vi] == curSelected) { + return vi; + } + } + return 0; + } /** * Update function - main game loop @@ -756,9 +811,12 @@ class FreeplayState extends MusicBeatState { Conductor.songPosition = FlxG.sound.music.time; bgZoom = FlxMath.lerp(defaultBgZoom, bgZoom, Math.exp(-elapsed * 3.125)); - bg.scale.set(bgZoom, bgZoom); - bg.updateHitbox(); - bg.screenCenter(); + if (_lastAppliedBgZoom < 0 || Math.abs(bgZoom - _lastAppliedBgZoom) > 0.0008) { + bg.scale.set(bgZoom, bgZoom); + bg.updateHitbox(); + bg.screenCenter(); + _lastAppliedBgZoom = bgZoom; + } albumZoom = FlxMath.lerp(defaultAlbumZoom, albumZoom, Math.exp(-elapsed * 4)); albumZoom = Math.min(albumZoom, 1.01); @@ -769,9 +827,12 @@ class FreeplayState extends MusicBeatState { var centerX:Float = baseX + (baseSize / 2); var centerY:Float = baseY + (baseSize / 2); - album.scale.set(albumZoom, albumZoom); - album.updateHitbox(); - + if (_lastAppliedAlbumZoom < 0 || Math.abs(albumZoom - _lastAppliedAlbumZoom) > 0.001) { + album.scale.set(albumZoom, albumZoom); + album.updateHitbox(); + _lastAppliedAlbumZoom = albumZoom; + } + album.x = centerX - (album.width / 2); album.y = centerY - (album.height / 2); @@ -786,13 +847,96 @@ class FreeplayState extends MusicBeatState { updateDynamicData(); var shiftMult:Int = 1; - if((FlxG.keys.pressed.SHIFT || (touchPad != null && touchPad.buttonZ.pressed)) && !player.playingMusic) + if((FlxG.keys.pressed.SHIFT || (touchPad != null && touchPad.buttonZ.pressed))) shiftMult = 3; // Disable navigation when typing in search var isTyping:Bool = (PsychUIInputText.focusOn == searchInput); - if (!player.playingMusic && !isTyping) { + if (!isTyping) { + #if mobile + var touchSongNavigationActive:Bool = false; + var touchDiffNavigationActive:Bool = false; + var anyTouchInDiffArea:Bool = isAnyTouchInDifficultyArea(); + + if (anyTouchInDiffArea) + { + if (difficultyScroll != null) + { + var diffScrollDelta = difficultyScroll.update(); + touchDiffNavigationActive = difficultyScroll.isTouchActive() || difficultyScroll.isCurrentlyScrolling(); + + if (Math.abs(diffScrollDelta) > 0.01 && Difficulty.list.length > 0) + { + diffViewOffset += -diffScrollDelta / 120; + diffViewOffset = FlxMath.bound(diffViewOffset, 0, Difficulty.list.length - 1); + } + + // If the difficulty scroll detected a short tap, forward it to the + // generic pointer handler so single taps can select a pill. + if (difficultyScroll.wasTapped()) { + var tapPos = difficultyScroll.getTapPosition(); + if (tapPos != null) { + handleFreeplayPointerPress(tapPos.x, tapPos.y); + } + } + + if (difficultyScroll.didReleaseScroll() && Difficulty.list.length > 0) + { + var snappedDiff:Int = Math.round(diffViewOffset); + if (snappedDiff < 0) snappedDiff = 0; + else if (snappedDiff > Difficulty.list.length - 1) snappedDiff = Difficulty.list.length - 1; + + if (snappedDiff != curDifficulty) + { + changeDiff(snappedDiff - curDifficulty); + FlxG.sound.play(Paths.sound('scrollMenu'), 0.4); + } + diffViewOffset = snappedDiff; + } + } + } + else if (touchScroll != null) + { + var scrollDelta = touchScroll.update(); + touchSongNavigationActive = touchScroll.isTouchActive() || touchScroll.isCurrentlyScrolling(); + + var visibleTouchIndices:Array = getVisibleSongIndices(); + if (visibleTouchIndices.length > 0) + { + if (Math.abs(scrollDelta) > 0.01) + { + viewOffset += -scrollDelta / 74; + viewOffset = FlxMath.bound(viewOffset, 0, visibleTouchIndices.length - 1); + } + + if (touchScroll.didReleaseScroll()) + { + var snappedVisibleIndex:Int = Math.round(viewOffset); + if (snappedVisibleIndex < 0) snappedVisibleIndex = 0; + else if (snappedVisibleIndex > visibleTouchIndices.length - 1) snappedVisibleIndex = visibleTouchIndices.length - 1; + + viewOffset = snappedVisibleIndex; + } + } + } + + var touchPadNavigatingSongs:Bool = (touchPad != null && (touchPad.buttonUp.pressed || touchPad.buttonDown.pressed || touchPad.buttonUp.justPressed || touchPad.buttonDown.justPressed)); + var touchPadNavigatingDiffs:Bool = (touchPad != null && (touchPad.buttonLeft.pressed || touchPad.buttonRight.pressed || touchPad.buttonLeft.justPressed || touchPad.buttonRight.justPressed)); + var allowSongDigitalNav:Bool = !touchSongNavigationActive || touchPadNavigatingSongs; + var allowDiffDigitalNav:Bool = !touchDiffNavigationActive || touchPadNavigatingDiffs; + var allowPointerClick:Bool = !touchSongNavigationActive && !touchDiffNavigationActive; + + if (touchScroll != null && touchScroll.wasTapped() && allowPointerClick) + { + var tapPos = touchScroll.getTapPosition(); + if (tapPos != null) + { + handleFreeplayPointerPress(tapPos.x, tapPos.y); + } + } + #end + var mouseOverPills:Bool = FlxG.mouse.y > 475 && FlxG.mouse.y < 515; if (FlxG.mouse.wheel != 0 && !mouseOverPills) { @@ -807,109 +951,69 @@ class FreeplayState extends MusicBeatState { FlxG.sound.play(Paths.sound('scrollMenu'), 0.2); } - if (FlxG.mouse.justPressed) { - if (playIcon != null && FlxG.mouse.overlaps(playIcon)) { - playSong(); - } - else if ((starIcon != null && FlxG.mouse.overlaps(starIcon)) || (starFullIcon != null && FlxG.mouse.overlaps(starFullIcon))) { - toggleFavorite(); - } - else if (allLevels != null && FlxG.mouse.overlaps(allLevels)) { - if(showingFavorites) { - showingFavorites = false; - _visCacheValid = false; - refreshSongList(); - FlxG.sound.play(Paths.sound('scrollMenu'), 0.4); - } - } - else if (favorites != null && FlxG.mouse.overlaps(favorites)) { - if(!showingFavorites) { - showingFavorites = true; - _visCacheValid = false; - refreshSongList(); - FlxG.sound.play(Paths.sound('scrollMenu'), 0.4); - } - } - else { - var clickedCard:Bool = false; - for (i in 0...cardsGroup.members.length) { - var card = cardsGroup.members[i]; - if (card != null && card.visible && card.alpha > 0.1 && FlxG.mouse.overlaps(card)) { - if(i < songs.length && i != curSelected) { - var visibleIndices:Array = []; - if(showingFavorites) { - for(si in 0...songs.length) { - if(songs[si].isFavorite) { - visibleIndices.push(si); - } - } - } else { - for(si in 0...songs.length) { - visibleIndices.push(si); - } - } - - var currentVisIndex:Int = 0; - var targetVisIndex:Int = 0; - for(vi in 0...visibleIndices.length) { - if(visibleIndices[vi] == curSelected) currentVisIndex = vi; - if(visibleIndices[vi] == i) targetVisIndex = vi; - } - - var change:Int = targetVisIndex - currentVisIndex; - changeSelection(change, true, false); - } - clickedCard = true; - break; - } - } - - if (!clickedCard) { - for (i in 0...diffsGroup.members.length) { - var diffIcon = diffsGroup.members[i]; - if (diffIcon != null && diffIcon.alpha > 0.1 && FlxG.mouse.overlaps(diffIcon)) { - var globalIdx:Int = diffIcon.ID; - if (curDifficulty != globalIdx) { - curDifficulty = globalIdx; - changeDiff(); - FlxG.sound.play(Paths.sound('scrollMenu'), 0.4); - } - break; - } - } - } - } + if ( + FlxG.mouse.justPressed + #if mobile + && allowPointerClick + #end + ) { + handleFreeplayPointerPress(FlxG.mouse.x, FlxG.mouse.y); } - if(controls.UI_UP_P) { - changeSelection(-shiftMult); - holdTime = 0; - } - if(controls.UI_DOWN_P) { - changeSelection(shiftMult); - holdTime = 0; - } + if( + #if mobile + allowSongDigitalNav + #else + true + #end + ) { + var songUpPressed:Bool = controls.UI_UP_P || (touchPad != null && touchPad.buttonUp.justPressed); + var songDownPressed:Bool = controls.UI_DOWN_P || (touchPad != null && touchPad.buttonDown.justPressed); + var songUpHeld:Bool = controls.UI_UP || (touchPad != null && touchPad.buttonUp.pressed); + var songDownHeld:Bool = controls.UI_DOWN || (touchPad != null && touchPad.buttonDown.pressed); + + if(songUpPressed) { + changeSelection(-shiftMult); + holdTime = 0; + } + if(songDownPressed) { + changeSelection(shiftMult); + holdTime = 0; + } - if(controls.UI_DOWN || controls.UI_UP) { - var checkLastHold:Int = Math.floor((holdTime - 0.5) * 10); - holdTime += elapsed; - var checkNewHold:Int = Math.floor((holdTime - 0.5) * 10); + if(songDownHeld || songUpHeld) { + var checkLastHold:Int = Math.floor((holdTime - 0.5) * 10); + holdTime += elapsed; + var checkNewHold:Int = Math.floor((holdTime - 0.5) * 10); - if(holdTime > 0.5 && checkNewHold - checkLastHold > 0) - changeSelection((checkNewHold - checkLastHold) * (controls.UI_UP ? -shiftMult : shiftMult)); + if(holdTime > 0.5 && checkNewHold - checkLastHold > 0) + changeSelection((checkNewHold - checkLastHold) * (songUpHeld ? -shiftMult : shiftMult)); + } } - if(controls.UI_LEFT_P) { + if( + #if mobile + allowDiffDigitalNav + #else + true + #end + && (controls.UI_LEFT_P || (touchPad != null && touchPad.buttonLeft.justPressed))) { changeDiff(-1); FlxG.sound.play(Paths.sound('scrollMenu'), 0.4); } - if(controls.UI_RIGHT_P) { + if( + #if mobile + allowDiffDigitalNav + #else + true + #end + && (controls.UI_RIGHT_P || (touchPad != null && touchPad.buttonRight.justPressed))) { changeDiff(1); FlxG.sound.play(Paths.sound('scrollMenu'), 0.4); } } - if (FlxG.keys.justPressed.TAB && !player.playingMusic && !isTyping) { + if (FlxG.keys.justPressed.TAB && !isTyping) { viewingOpponentScores = !viewingOpponentScores; FlxG.sound.play(Paths.sound('scrollMenu')); @@ -927,41 +1031,32 @@ class FreeplayState extends MusicBeatState { } if ((controls.BACK || (touchPad != null && touchPad.buttonB.justPressed)) && !isTyping) { - if (player.playingMusic) { - FlxG.sound.music.stop(); - destroyFreeplayVocals(); - FlxG.sound.music.volume = 0; - instPlaying = -1; - player.playingMusic = false; - player.switchPlayMusic(); - } else { - persistentUpdate = false; - FlxG.sound.play(Paths.sound('cancelMenu')); - MusicBeatState.switchState(new funkin.ui.mainmenu.MainMenuState()); - } + persistentUpdate = false; + FlxG.sound.play(Paths.sound('cancelMenu')); + MusicBeatState.switchState(new funkin.ui.mainmenu.MainMenuState()); } // Gameplay changers - if((FlxG.keys.justPressed.CONTROL || (touchPad != null && touchPad.buttonC.justPressed)) && !player.playingMusic && !isTyping) { + if((FlxG.keys.justPressed.CONTROL || (touchPad != null && touchPad.buttonC.justPressed)) && !isTyping) { persistentUpdate = false; removeTouchPad(); openSubState(new GameplayChangersSubstate()); } if((FlxG.keys.justPressed.SPACE || (touchPad != null && touchPad.buttonX.justPressed)) && !isTyping) { - if(instPlaying != curSelected && !player.playingMusic) { + if(instPlaying != curSelected) { playInstPreview(); - } else if (instPlaying == curSelected && !player.playingMusic) { + } else if (instPlaying == curSelected) { stopInstPreview(); } } - if ((controls.ACCEPT || (touchPad != null && touchPad.buttonA.justPressed)) && !player.playingMusic && !isTyping) { + if ((controls.ACCEPT || (touchPad != null && touchPad.buttonA.justPressed)) && !isTyping) { playSong(); } // Reset score - if((controls.RESET || (touchPad != null && touchPad.buttonY.justPressed)) && !player.playingMusic && !isTyping) { + if((controls.RESET || (touchPad != null && touchPad.buttonY.justPressed)) && !isTyping) { persistentUpdate = false; removeTouchPad(); openSubState(new ResetScoreSubState(songs[curSelected].songName, curDifficulty, songs[curSelected].songCharacter)); @@ -1123,12 +1218,233 @@ class FreeplayState extends MusicBeatState { else return 0xCCCCCC; // Default gray } + + inline function imageExists(imageKey:String):Bool { + return imageKey != null && imageKey.length > 0 && Paths.fileExists('images/$imageKey.png', IMAGE); + } + + function getCurrentWeekDataForSelection():WeekData { + if (songs == null || songs.length == 0 || curSelected < 0 || curSelected >= songs.length) + return null; + + var weekIndex:Int = songs[curSelected].week; + if (weekIndex < 0 || weekIndex >= WeekData.weeksList.length) + return null; + + return WeekData.weeksLoaded.get(WeekData.weeksList[weekIndex]); + } + + function resolveFreeplayBgAsset(song:SongMetadata, weekData:WeekData):String { + return 'menuDesat'; + } + + inline function getSongFolderKey(song:SongMetadata):String { + return (song != null && song.folder != null) ? Paths.formatToSongPath(song.folder) : ''; + } + + function transitionToBackground(assetKey:String):Void { + if (bgFadeTween != null) { + bgFadeTween.cancel(); + bgFadeTween = null; + } + + bgTransition.loadGraphic(Paths.image(assetKey)); + bgTransition.antialiasing = ClientPrefs.data.antialiasing; + #if !mobile + bgTransition.shader = blurEffect.shader; + #end + bgTransition.alpha = 0; + bgTransition.visible = true; + bgTransition.scale.set(bgZoom, bgZoom); + bgTransition.updateHitbox(); + bgTransition.screenCenter(); + + bgFadeTween = FlxTween.tween(bgTransition, {alpha: 1}, 0.22, { + onComplete: function(_:FlxTween) { + bg.loadGraphic(Paths.image(assetKey)); + bg.antialiasing = ClientPrefs.data.antialiasing; + #if !mobile + bg.shader = blurEffect.shader; + #end + _lastAppliedBgZoom = -1; + + bgTransition.visible = false; + bgTransition.alpha = 0; + bgFadeTween = null; + } + }); + } + + function resolveAlbumArtFromJson(songKey:String):String { + var jsonPath:String = 'images/albumRoll/$songKey.json'; + if (!Paths.fileExists(jsonPath, TEXT)) + return null; + + try { + var raw:String = Paths.getTextFromFile(jsonPath, false); + if (raw == null || raw.length == 0) + return null; + + var parsed:Dynamic = Json.parse(raw); + if (parsed == null) + return null; + + var imageField:Dynamic = null; + if (Reflect.hasField(parsed, 'image')) imageField = Reflect.field(parsed, 'image'); + else if (Reflect.hasField(parsed, 'album')) imageField = Reflect.field(parsed, 'album'); + else if (Reflect.hasField(parsed, 'art')) imageField = Reflect.field(parsed, 'art'); + else if (Reflect.hasField(parsed, 'key')) imageField = Reflect.field(parsed, 'key'); + + if (imageField == null) + return null; + + var imageKey:String = Std.string(imageField); + if (imageKey == null || imageKey.length == 0) + return null; + + if (!imageKey.startsWith('albumRoll/')) + imageKey = 'albumRoll/' + Paths.formatToSongPath(imageKey); + + return imageExists(imageKey) ? imageKey : null; + } catch (e:Dynamic) { + trace('Failed to parse album JSON for $songKey: $e'); + } + + return null; + } + + function resolveAlbumArtFromSongMetadata(songKey:String):String { + var metadataPath:String = 'data/$songKey/metadata.json'; + if (!Paths.fileExists(metadataPath, TEXT)) + return null; + + try { + var raw:String = Paths.getTextFromFile(metadataPath, false); + if (raw == null || raw.length == 0) + return null; + + var parsed:Dynamic = Json.parse(raw); + if (parsed == null || !Reflect.hasField(parsed, 'albumId')) + return null; + + var albumId:Dynamic = Reflect.field(parsed, 'albumId'); + if (albumId == null) + return null; + + var albumKey:String = Paths.formatToSongPath(Std.string(albumId)); + if (albumKey == null || albumKey.length == 0) + return null; + + var fullKey:String = 'albumRoll/$albumKey'; + return imageExists(fullKey) ? fullKey : null; + } catch (e:Dynamic) { + trace('Failed to parse song metadata for $songKey: $e'); + } + + return null; + } + + function resolveAlbumArtAsset(song:SongMetadata, weekData:WeekData):String { + var songKey:String = Paths.formatToSongPath(song.songName); + + var fromMetadata:String = resolveAlbumArtFromSongMetadata(songKey); + if (fromMetadata != null) + return fromMetadata; + + var directSong:String = 'albumRoll/$songKey'; + if (imageExists(directSong)) + return directSong; + + var fromJson:String = resolveAlbumArtFromJson(songKey); + if (fromJson != null) + return fromJson; + + if (weekData != null && weekData.songs != null && weekData.songs.length > 1) { + var weekSongKeys:Array = []; + for (entry in weekData.songs) { + if (entry != null && entry.length > 0 && entry[0] != null) + weekSongKeys.push(Paths.formatToSongPath(Std.string(entry[0]))); + } + + var curIndex:Int = weekSongKeys.indexOf(songKey); + if (curIndex > -1) { + if (curIndex < weekSongKeys.length - 1) { + var pairForward:String = 'albumRoll/' + songKey + '_' + weekSongKeys[curIndex + 1]; + if (imageExists(pairForward)) + return pairForward; + } + + if (curIndex > 0) { + var pairBackward:String = 'albumRoll/' + weekSongKeys[curIndex - 1] + '_' + songKey; + if (imageExists(pairBackward)) + return pairBackward; + } + } + + var groupedWeek:String = 'albumRoll/' + weekSongKeys.join('_'); + if (imageExists(groupedWeek)) + return groupedWeek; + } + + if (weekData != null) { + var weekFileKey:String = Paths.formatToSongPath(weekData.fileName); + var weekNameKey:String = Paths.formatToSongPath(weekData.weekName); + + var weekCandidates:Array = [ + 'albumRoll/' + weekFileKey, + 'albumRoll/week-' + weekFileKey, + 'albumRoll/' + weekNameKey + ]; + + for (candidate in weekCandidates) { + if (imageExists(candidate)) + return candidate; + } + } + + return 'albumRoll/example'; + } + + function applySelectionVisualAssets():Void { + if (songs == null || songs.length == 0 || curSelected < 0 || curSelected >= songs.length) + return; + + var weekData:WeekData = getCurrentWeekDataForSelection(); + var selectedSong:SongMetadata = songs[curSelected]; + + var bgAsset:String = resolveFreeplayBgAsset(selectedSong, weekData); + var bgFolderKey:String = getSongFolderKey(selectedSong); + if (bgAsset != currentBgAssetKey || bgFolderKey != currentBgFolderKey) { + currentBgAssetKey = bgAsset; + currentBgFolderKey = bgFolderKey; + transitionToBackground(bgAsset); + } + + var albumAsset:String = resolveAlbumArtAsset(selectedSong, weekData); + if (albumAsset != currentAlbumAssetKey) { + currentAlbumAssetKey = albumAsset; + album.loadGraphic(Paths.image(albumAsset)); + album.antialiasing = ClientPrefs.data.antialiasing; + album.setGraphicSize(100, 100); + album.updateHitbox(); + _lastAppliedAlbumZoom = -1; + } + } + + inline function chartExistsForDifficulty(songName:String, diffName:String):Bool { + var postfix:String = ''; + if (Paths.formatToSongPath(diffName) != Paths.formatToSongPath(Difficulty.getDefault())) + postfix = '-' + Paths.formatToSongPath(diffName); + + var chartFile:String = songName + postfix; + return Paths.fileExists('data/$songName/$chartFile.json', TEXT); + } /** * Change song selection */ function changeSelection(change:Int = 0, playSound:Bool = true, scrollView:Bool = true):Void { - if (player.playingMusic || songs.length == 0) + if (songs.length == 0) return; // Periodic GC for large song lists to prevent memory buildup @@ -1192,6 +1508,8 @@ class FreeplayState extends MusicBeatState { } else { Mods.currentModDirectory = ''; } + + applySelectionVisualAssets(); // Clear bitmap cache of unused songs to reduce memory usage #if MODS_ALLOWED @@ -1216,7 +1534,7 @@ class FreeplayState extends MusicBeatState { var savedDiff:String = songs[curSelected].lastDifficulty; var lastDiff:Int = Difficulty.list.indexOf(lastDifficultyName); - if(savedDiff != null && !Difficulty.list.contains(savedDiff) && Difficulty.list.contains(savedDiff)) + if(savedDiff != null && Difficulty.list.contains(savedDiff)) curDifficulty = Math.round(Math.max(0, Difficulty.list.indexOf(savedDiff))); else if(lastDiff > -1) curDifficulty = lastDiff; @@ -1247,7 +1565,7 @@ class FreeplayState extends MusicBeatState { * Change difficulty */ function changeDiff(change:Int = 0):Void { - if (player.playingMusic || Difficulty.list.length == 0) + if (Difficulty.list.length == 0) return; curDifficulty = FlxMath.wrap(curDifficulty + change, 0, Difficulty.list.length-1); @@ -1275,6 +1593,11 @@ class FreeplayState extends MusicBeatState { */ function playSong():Void { persistentUpdate = false; + if (!songs[curSelected].isStepMania) + Mods.currentModDirectory = songs[curSelected].folder; + else + Mods.currentModDirectory = ''; + var songLowercase:String = Paths.formatToSongPath(songs[curSelected].songName); var poop:String = Highscore.formatSong(songLowercase, curDifficulty); @@ -1298,8 +1621,8 @@ class FreeplayState extends MusicBeatState { @:privateAccess if(PlayState._lastLoadedModDirectory != Mods.currentModDirectory) { - Paths.clearUnusedMemory(); - Mods.loadTopMod(); + trace('CHANGED MOD DIRECTORY, RELOADING STUFF'); + Paths.freeGraphicsFromMemory(); } LoadingState.prepareToSong(); @@ -1330,9 +1653,15 @@ class FreeplayState extends MusicBeatState { var songName:String = Paths.formatToSongPath(songs[curSelected].songName); var availableDiffs:Array = []; - - for (diff in Difficulty.list) { - availableDiffs.push(diff); + + Difficulty.loadFromWeek(); + var weekDiffs:Array = Difficulty.list.copy(); + if (weekDiffs == null || weekDiffs.length == 0) + weekDiffs = Difficulty.defaultList.copy(); + + for (diff in weekDiffs) { + if (chartExistsForDifficulty(songName, diff)) + availableDiffs.push(diff); } // Only check for erect/nightmare in base game songs (not mods) @@ -1341,22 +1670,17 @@ class FreeplayState extends MusicBeatState { if(isBaseGame) { var erectDiffs:Array = ['Erect', 'Nightmare']; for (diff in erectDiffs) { - if (!availableDiffs.contains(diff)) { - var diffFile:String = Highscore.formatSong(songName, Difficulty.list.indexOf(diff)); - var path:String = Paths.getPath('data/$songName/$diffFile.json', TEXT); - - #if MODS_ALLOWED - if(FileSystem.exists(path) || Assets.exists(path)) { - availableDiffs.push(diff); - } - #else - if(Assets.exists(path)) { - availableDiffs.push(diff); - } - #end - } + if (!availableDiffs.contains(diff) && chartExistsForDifficulty(songName, diff)) + availableDiffs.push(diff); } } + + if (availableDiffs.length == 0) { + if (chartExistsForDifficulty(songName, Difficulty.getDefault())) + availableDiffs.push(Difficulty.getDefault()); + else + availableDiffs.push('Normal'); + } Difficulty.list = availableDiffs; } @@ -1523,33 +1847,54 @@ class FreeplayState extends MusicBeatState { var noteCount:Int = 0; var lastNoteTime:Float = 0; - var totalBeats:Float = 0; - - for(section in chart.notes) { - if(section == null || section.sectionNotes == null) continue; - - var sectionBeats:Float = section.sectionBeats; - if(Math.isNaN(sectionBeats) || sectionBeats <= 0) sectionBeats = 4; - totalBeats += sectionBeats; - - for(note in section.sectionNotes) { - if(note == null || note.length < 2) continue; - - var noteData:Int = Std.int(note[1]); - var strumTime:Float = note[0]; - - // Skip events (noteData < 0 or > 7) and invalid timestamps. + var useV2Metadata:Bool = chart.format != null && chart.format.startsWith('psych_v2') && chart.notesV2 != null && chart.notesV2.length > 0; + + if(useV2Metadata) + { + for(v2Note in chart.notesV2) + { + if(v2Note == null) continue; + + var strumTime:Float = v2Note.t; + var noteData:Int = v2Note.d; + var holdLength:Float = v2Note.l; + if(Math.isNaN(holdLength) || holdLength < 0) holdLength = 0; + if(noteData < 0 || noteData > 7 || strumTime < 0) continue; - - if(strumTime > lastNoteTime) { - lastNoteTime = strumTime; + + var noteEnd:Float = strumTime + holdLength; + if(noteEnd > lastNoteTime) { + lastNoteTime = noteEnd; + } + + // psych_v2: 0-3 = player lanes, 4-7 = opponent lanes + if(noteData < 4) noteCount++; + } + } + else + { + for(section in chart.notes) { + if(section == null || section.sectionNotes == null) continue; + + for(note in section.sectionNotes) { + if(note == null || note.length < 2) continue; + + var noteData:Int = Std.int(note[1]); + var strumTime:Float = note[0]; + + // Skip events (noteData < 0 or > 7) and invalid timestamps. + if(noteData < 0 || noteData > 7 || strumTime < 0) continue; + + if(strumTime > lastNoteTime) { + lastNoteTime = strumTime; + } + + // mustHitSection=true → player owns lanes 0-3 + // mustHitSection=false → player owns lanes 4-7 + var isPlayerNote:Bool = section.mustHitSection ? (noteData < 4) : (noteData >= 4); + + if(isPlayerNote) noteCount++; } - - // mustHitSection=true → player owns lanes 0-3 - // mustHitSection=false → player owns lanes 4-7 - var isPlayerNote:Bool = section.mustHitSection ? (noteData < 4) : (noteData >= 4); - - if(isPlayerNote) noteCount++; } } @@ -1567,28 +1912,46 @@ class FreeplayState extends MusicBeatState { var sectionDensities:Array = []; var sectionDuration:Float = lastNoteTime / BAR_COUNT; + if(sectionDuration <= 0 || Math.isNaN(sectionDuration)) sectionDuration = 1; for(i in 0...BAR_COUNT) { var sectionStart:Float = i * sectionDuration; var sectionEnd:Float = (i + 1) * sectionDuration; var sectionNoteCount:Int = 0; - - for(section in chart.notes) { - if(section == null || section.sectionNotes == null) continue; - - for(note in section.sectionNotes) { - if(note == null || note.length < 2) continue; - - var noteData:Int = Std.int(note[1]); - var strumTime:Float = note[0]; - - // Skip events (noteData < 0 or > 7) and invalid timestamps. + + if(useV2Metadata) + { + for(v2Note in chart.notesV2) + { + if(v2Note == null) continue; + var noteData:Int = v2Note.d; + var strumTime:Float = v2Note.t; + if(noteData < 0 || noteData > 7 || strumTime < 0) continue; if(strumTime < sectionStart || strumTime >= sectionEnd) continue; - - var isPlayerNote:Bool = section.mustHitSection ? (noteData < 4) : (noteData >= 4); - - if(isPlayerNote) sectionNoteCount++; + + if(noteData < 4) sectionNoteCount++; + } + } + else + { + for(section in chart.notes) { + if(section == null || section.sectionNotes == null) continue; + + for(note in section.sectionNotes) { + if(note == null || note.length < 2) continue; + + var noteData:Int = Std.int(note[1]); + var strumTime:Float = note[0]; + + // Skip events (noteData < 0 or > 7) and invalid timestamps. + if(noteData < 0 || noteData > 7 || strumTime < 0) continue; + if(strumTime < sectionStart || strumTime >= sectionEnd) continue; + + var isPlayerNote:Bool = section.mustHitSection ? (noteData < 4) : (noteData >= 4); + + if(isPlayerNote) sectionNoteCount++; + } } } @@ -1618,51 +1981,55 @@ class FreeplayState extends MusicBeatState { */ function playInstPreview():Void { if(songs.length == 0 || curSelected >= songs.length) return; - - var songName:String = Paths.formatToSongPath(songs[curSelected].songName); - - // Remove previous song from Paths cache so the GC can actually free it - if(_prevInstSongName != null && _prevInstSongName != songName) { - var toRemove:Array = []; - for(key in Paths.currentTrackedSounds.keys()) { - if(key.contains('/' + _prevInstSongName + '/')) { - toRemove.push(key); - } - } - for(key in toRemove) { - openfl.Assets.cache.clear(key); - Paths.currentTrackedSounds.remove(key); - } - openfl.system.System.gc(); - } - _prevInstSongName = songName; - - try { - // playMusic creates a proper streaming FlxSound with a valid OpenAL - // __audioSource — unlike loadEmbedded which can produce null on native. - FlxG.sound.playMusic(Paths.inst(songName), 0, true); - FlxG.sound.music.fadeIn(1.0, 0, 0.7); - instSound = FlxG.sound.music; // Keep public alias working - instPlaying = curSelected; - - Conductor.bpm = currentBPM; - #if funkin.vis - _analyzer = null; - _analyzerLevels = null; - _needsAnalyzerInit = true; - #end - - } catch(e:Dynamic) { - trace('Error loading inst for $songName: $e'); - FlxG.sound.playMusic(Paths.music('freakyMenu'), 0.7); + previewLoadToken++; + var requestToken:Int = previewLoadToken; + var requestedIndex:Int = curSelected; + var songName:String = Paths.formatToSongPath(songs[requestedIndex].songName); + + if(previewLoadTimer != null) { + previewLoadTimer.cancel(); + previewLoadTimer = null; } + + previewLoadTimer = new FlxTimer().start(PREVIEW_LOAD_DELAY, function(_:FlxTimer) { + previewLoadTimer = null; + + if(requestToken != previewLoadToken || songs.length == 0 || requestedIndex != curSelected) + return; + + _prevInstSongName = songName; + + try { + FlxG.sound.playMusic(Paths.inst(songName), 0, true); + FlxG.sound.music.fadeIn(1.0, 0, 0.7); + instSound = FlxG.sound.music; + instPlaying = requestedIndex; + + Conductor.bpm = currentBPM; + + #if funkin.vis + _analyzer = null; + _analyzerLevels = null; + _needsAnalyzerInit = true; + #end + } catch(e:Dynamic) { + trace('Error loading inst for $songName: $e'); + FlxG.sound.playMusic(Paths.music('freakyMenu'), 0.7); + } + }); } /** * Stop instrumental preview and return to freakyMenu. */ function stopInstPreview():Void { + previewLoadToken++; + if(previewLoadTimer != null) { + previewLoadTimer.cancel(); + previewLoadTimer = null; + } + instPlaying = -1; instSound = null; @@ -1964,38 +2331,51 @@ class FreeplayState extends MusicBeatState { _analyzer.maxFreq = 18000; _analyzer.minDb = -80; _analyzer.maxDb = -15; - #if !web + #if mobile + _analyzer.fftN = 256; + #elseif !web _analyzer.fftN = 512; #end _needsAnalyzerInit = false; } } + _vizUpdateAccum += elapsed; if(vizBarsGroup != null) { var vizBarW:Int = Std.int(FlxG.width / VIZ_BAR_COUNT); - if(_analyzer != null) { - _analyzerLevels = _analyzer.getLevels(_analyzerLevels); - for(i in 0...vizBarsGroup.members.length) { - var vbar = vizBarsGroup.members[i]; - if(vbar == null) continue; - var level:Float = (i < _analyzerLevels.length) ? _analyzerLevels[i].value : 0.0; - var h:Int = Std.int(Math.max(2, level * VIZ_BAR_MAX_H)); - vbar.setGraphicSize(vizBarW - 1, h); - vbar.updateHitbox(); - vbar.x = i * vizBarW; - vbar.y = FlxG.height - h; - vbar.color = _curAccentColor; - vbar.alpha = 1.0; - } - } else { - for(i in 0...vizBarsGroup.members.length) { - var vbar = vizBarsGroup.members[i]; - if(vbar == null) continue; - vbar.setGraphicSize(vizBarW - 1, 2); - vbar.updateHitbox(); - vbar.y = FlxG.height - 2; - vbar.alpha = 1.0; + var vizOffsetX:Float = (vizBarW - Std.int(Math.max(1, vizBarW * VIZ_BAR_FILL))) * 0.5; + + if (_vizUpdateAccum >= VIZ_UPDATE_INTERVAL) + { + _vizUpdateAccum = 0; + if(_analyzer != null) { + _analyzerLevels = _analyzer.getLevels(_analyzerLevels); + for(i in 0...vizBarsGroup.members.length) { + var level:Float = (i < _analyzerLevels.length) ? _analyzerLevels[i].value : 0.0; + _vizTargetHeights[i] = Math.max(VIZ_MIN_H, level * VIZ_BAR_MAX_H); + } + } else { + for(i in 0...vizBarsGroup.members.length) { + _vizTargetHeights[i] = VIZ_MIN_H; + } } } + + var lerpFactor:Float = 1 - Math.exp(-elapsed * VIZ_SMOOTH_SPEED); + for(i in 0...vizBarsGroup.members.length) { + var vbar = vizBarsGroup.members[i]; + if(vbar == null) continue; + + var curH:Float = _vizCurrentHeights[i]; + var targetH:Float = _vizTargetHeights[i]; + curH = FlxMath.lerp(targetH, curH, 1 - lerpFactor); + _vizCurrentHeights[i] = curH; + + vbar.scale.y = curH / VIZ_BAR_MAX_H; + vbar.x = i * vizBarW + vizOffsetX; + vbar.y = FlxG.height - curH; + vbar.color = _curAccentColor; + vbar.alpha = 1.0; + } } #end @@ -2078,7 +2458,7 @@ class FreeplayState extends MusicBeatState { #if mobile removeTouchPad(); - addTouchPad('UP_DOWN', 'A_B_C_X_Y_Z'); + addTouchPad('LEFT_FULL', 'A_B_C_X_Y_Z'); addTouchPadCamera(); if(touchPad != null) { touchPad.visible = true; @@ -2108,6 +2488,13 @@ class FreeplayState extends MusicBeatState { previewTimer.cancel(); previewTimer = null; } + + if(previewLoadTimer != null) { + previewLoadTimer.cancel(); + previewLoadTimer = null; + } + + previewLoadToken++; if (instSound != null && instSound.playing) { instSound.stop(); @@ -2203,6 +2590,100 @@ class FreeplayState extends MusicBeatState { if (!FlxG.sound.music.playing && !stopMusicPlay) FlxG.sound.playMusic(Paths.music('freakyMenu')); } + + #if mobile + function isAnyTouchInDifficultyArea():Bool { + for (touch in FlxG.touches.list) { + if (touch != null && (touch.pressed || touch.justPressed)) { + if (touch.screenY >= 470 && touch.screenY <= 530 && touch.screenX >= PILL_CLIP_X_MIN && touch.screenX <= PILL_CLIP_X_MAX) + return true; + } + } + return false; + } + #end + + function handleFreeplayPointerPress(x:Float, y:Float):Void { + var point = new FlxPoint(x, y); + + if (playIcon != null && playIcon.overlapsPoint(point)) { + playSong(); + return; + } + + if ((starIcon != null && starIcon.overlapsPoint(point)) || (starFullIcon != null && starFullIcon.overlapsPoint(point))) { + toggleFavorite(); + return; + } + + if (allLevels != null && allLevels.overlapsPoint(point)) { + if(showingFavorites) { + showingFavorites = false; + _visCacheValid = false; + refreshSongList(); + FlxG.sound.play(Paths.sound('scrollMenu'), 0.4); + } + return; + } + + if (favorites != null && favorites.overlapsPoint(point)) { + if(!showingFavorites) { + showingFavorites = true; + _visCacheValid = false; + refreshSongList(); + FlxG.sound.play(Paths.sound('scrollMenu'), 0.4); + } + return; + } + + var clickedCard:Bool = false; + for (i in 0...cardsGroup.members.length) { + var card = cardsGroup.members[i]; + if (card != null && card.visible && card.alpha > 0.1 && card.overlapsPoint(point)) { + if(i < songs.length && i != curSelected) { + var visibleIndices:Array = []; + if(showingFavorites) { + for(si in 0...songs.length) { + if(songs[si].isFavorite) { + visibleIndices.push(si); + } + } + } else { + for(si in 0...songs.length) { + visibleIndices.push(si); + } + } + + var currentVisIndex:Int = 0; + var targetVisIndex:Int = 0; + for(vi in 0...visibleIndices.length) { + if(visibleIndices[vi] == curSelected) currentVisIndex = vi; + if(visibleIndices[vi] == i) targetVisIndex = vi; + } + + var change:Int = targetVisIndex - currentVisIndex; + changeSelection(change, true, false); + } + clickedCard = true; + break; + } + } + + if (!clickedCard) { + for (i in 0...diffsGroup.members.length) { + var diffIcon = diffsGroup.members[i]; + if (diffIcon != null && diffIcon.alpha > 0.1 && diffIcon.overlapsPoint(point)) { + var globalIdx:Int = diffIcon.ID; + if (curDifficulty != globalIdx) { + curDifficulty = globalIdx; + changeDiff(); + FlxG.sound.play(Paths.sound('scrollMenu'), 0.4); + } + break; + } + } + } + } } // Song Metadata Class diff --git a/source/funkin/ui/freeplay/FreeplayState_Psych.hx b/source/funkin/ui/freeplay/FreeplayState_Psych.hx index 59ecca36196..689b9d8292b 100644 --- a/source/funkin/ui/freeplay/FreeplayState_Psych.hx +++ b/source/funkin/ui/freeplay/FreeplayState_Psych.hx @@ -1,14 +1,15 @@ -package states; - -import backend.WeekData; -import backend.Highscore; -import backend.Song; - -import objects.HealthIcon; -import objects.MusicPlayer; - -import options.GameplayChangersSubstate; -import substates.ResetScoreSubState; +package funkin.ui.freeplay; + +import funkin.data.story.level.WeekData; +import funkin.save.Highscore; +import funkin.data.song.Song; +import funkin.play.HealthIcon; +import funkin.ui.options.GameplayChangersSubstate; +import funkin.play.substates.ResetScoreSubState; +import funkin.ui.mainmenu.MainMenuState; +import funkin.ui.story.StoryMenuState; +import funkin.ui.ErrorState; +import funkin.ui.debug.MasterEditorMenu; import flixel.math.FlxMath; import flixel.util.FlxDestroyUtil; @@ -17,9 +18,9 @@ import openfl.utils.Assets; import haxe.Json; -class FreeplayState extends MusicBeatState +class FreeplayState_Psych extends MusicBeatState { - var songs:Array = []; + var songs:Array = []; var selector:FlxText; private static var curSelected:Int = 0; @@ -50,7 +51,7 @@ class FreeplayState extends MusicBeatState var bottomText:FlxText; var bottomBG:FlxSprite; - var player:MusicPlayer; + var player:PsychMusicPlayer; override function create() { @@ -70,9 +71,9 @@ class FreeplayState extends MusicBeatState { FlxTransitionableState.skipNextTransIn = true; persistentUpdate = false; - MusicBeatState.switchState(new states.ErrorState("NO WEEKS ADDED FOR FREEPLAY\n\nPress ACCEPT to go to the Week Editor Menu.\nPress BACK to return to Main Menu.", - function() MusicBeatState.switchState(new states.editors.WeekEditorState()), - function() MusicBeatState.switchState(new states.MainMenuState()))); + MusicBeatState.switchState(new ErrorState("NO WEEKS ADDED FOR FREEPLAY\n\nPress ACCEPT to go to the Week Editor Menu.\nPress BACK to return to Main Menu.", + function() MusicBeatState.switchState(new MasterEditorMenu()), + function() MusicBeatState.switchState(new MainMenuState()))); return; } @@ -183,24 +184,40 @@ class FreeplayState extends MusicBeatState bottomText.scrollFactor.set(); add(bottomText); - player = new MusicPlayer(this); - add(player); + player = new PsychMusicPlayer(this); changeSelection(); updateTexts(); super.create(); + + #if mobile + addTouchPad('LEFT_FULL', 'A_B_C_X_Y_Z'); + addTouchPadCamera(); + if(touchPad != null) + { + touchPad.visible = true; + touchPad.updateTrackedButtons(); + } + #end } override function closeSubState() { changeSelection(0, false); persistentUpdate = true; + #if mobile + if(touchPad != null) + { + touchPad.visible = true; + touchPad.updateTrackedButtons(); + } + #end super.closeSubState(); } public function addSong(songName:String, weekNum:Int, songCharacter:String, color:Int) { - songs.push(new SongMetadata(songName, weekNum, songCharacter, color)); + songs.push(new PsychSongMetadata(songName, weekNum, songCharacter, color)); } function weekIsLocked(name:String):Bool @@ -239,7 +256,7 @@ class FreeplayState extends MusicBeatState ratingSplit[1] += '0'; var shiftMult:Int = 1; - if(FlxG.keys.pressed.SHIFT) shiftMult = 3; + if(FlxG.keys.pressed.SHIFT || (touchPad != null && touchPad.buttonZ != null && touchPad.buttonZ.pressed)) shiftMult = 3; if (!player.playingMusic) { @@ -260,25 +277,27 @@ class FreeplayState extends MusicBeatState changeSelection(); holdTime = 0; } - if (controls.UI_UP_P) + if (controls.UI_UP_P || (touchPad != null && touchPad.buttonUp != null && touchPad.buttonUp.justPressed)) { changeSelection(-shiftMult); holdTime = 0; } - if (controls.UI_DOWN_P) + if (controls.UI_DOWN_P || (touchPad != null && touchPad.buttonDown != null && touchPad.buttonDown.justPressed)) { changeSelection(shiftMult); holdTime = 0; } - if(controls.UI_DOWN || controls.UI_UP) + var songUpHeld:Bool = controls.UI_UP || (touchPad != null && touchPad.buttonUp != null && touchPad.buttonUp.pressed); + var songDownHeld:Bool = controls.UI_DOWN || (touchPad != null && touchPad.buttonDown != null && touchPad.buttonDown.pressed); + if(songDownHeld || songUpHeld) { var checkLastHold:Int = Math.floor((holdTime - 0.5) * 10); holdTime += elapsed; var checkNewHold:Int = Math.floor((holdTime - 0.5) * 10); if(holdTime > 0.5 && checkNewHold - checkLastHold > 0) - changeSelection((checkNewHold - checkLastHold) * (controls.UI_UP ? -shiftMult : shiftMult)); + changeSelection((checkNewHold - checkLastHold) * (songUpHeld ? -shiftMult : shiftMult)); } if(FlxG.mouse.wheel != 0) @@ -288,19 +307,19 @@ class FreeplayState extends MusicBeatState } } - if (controls.UI_LEFT_P) + if (controls.UI_LEFT_P || (touchPad != null && touchPad.buttonLeft != null && touchPad.buttonLeft.justPressed)) { changeDiff(-1); _updateSongLastDifficulty(); } - else if (controls.UI_RIGHT_P) + else if (controls.UI_RIGHT_P || (touchPad != null && touchPad.buttonRight != null && touchPad.buttonRight.justPressed)) { changeDiff(1); _updateSongLastDifficulty(); } } - if (controls.BACK) + if (controls.BACK || (touchPad != null && touchPad.buttonB != null && touchPad.buttonB.justPressed)) { if (player.playingMusic) { @@ -323,12 +342,12 @@ class FreeplayState extends MusicBeatState } } - if(FlxG.keys.justPressed.CONTROL && !player.playingMusic) + if((FlxG.keys.justPressed.CONTROL || (touchPad != null && touchPad.buttonC != null && touchPad.buttonC.justPressed)) && !player.playingMusic) { persistentUpdate = false; openSubState(new GameplayChangersSubstate()); } - else if(FlxG.keys.justPressed.SPACE) + else if(FlxG.keys.justPressed.SPACE || (touchPad != null && touchPad.buttonX != null && touchPad.buttonX.justPressed)) { if(instPlaying != curSelected && !player.playingMusic) { @@ -403,7 +422,7 @@ class FreeplayState extends MusicBeatState player.pauseOrResume(!player.playing); } } - else if (controls.ACCEPT && !player.playingMusic) + else if ((controls.ACCEPT || (touchPad != null && touchPad.buttonA != null && touchPad.buttonA.justPressed)) && !player.playingMusic) { persistentUpdate = false; var songLowercase:String = Paths.formatToSongPath(songs[curSelected].songName); @@ -443,6 +462,7 @@ class FreeplayState extends MusicBeatState Paths.freeGraphicsFromMemory(); } LoadingState.prepareToSong(); + LoadingState.returnState = new FreeplayState_Psych(); LoadingState.loadAndSwitchState(new PlayState()); #if !SHOW_LOADING_SCREEN FlxG.sound.music.stop(); #end stopMusicPlay = true; @@ -452,7 +472,7 @@ class FreeplayState extends MusicBeatState DiscordClient.loadModRPC(); #end } - else if(controls.RESET && !player.playingMusic) + else if((controls.RESET || (touchPad != null && touchPad.buttonY != null && touchPad.buttonY.justPressed)) && !player.playingMusic) { persistentUpdate = false; openSubState(new ResetScoreSubState(songs[curSelected].songName, curDifficulty, songs[curSelected].songCharacter)); @@ -607,7 +627,7 @@ class FreeplayState extends MusicBeatState } } -class SongMetadata +class PsychSongMetadata { public var songName:String = ""; public var week:Int = 0; @@ -625,4 +645,46 @@ class SongMetadata this.folder = Mods.currentModDirectory; if(this.folder == null) this.folder = ''; } +} + +class PsychMusicPlayer +{ + public var playingMusic:Bool = false; + public var curTime:Float = 0; + + var state:FreeplayState_Psych; + + public var playing(get, never):Bool; + inline function get_playing():Bool + return FlxG.sound.music != null && FlxG.sound.music.playing; + + public function new(state:FreeplayState_Psych) + { + this.state = state; + } + + public function switchPlayMusic():Void + { + // Legacy Psych Freeplay does not need extra HUD widgets for preview controls. + } + + public function pauseOrResume(?resume:Bool = false):Void + { + if (FlxG.sound.music == null) + return; + + var shouldPlay:Bool = resume || !FlxG.sound.music.playing; + if (shouldPlay) + { + FlxG.sound.music.play(); + if (FreeplayState_Psych.vocals != null) FreeplayState_Psych.vocals.play(); + if (FreeplayState_Psych.opponentVocals != null) FreeplayState_Psych.opponentVocals.play(); + } + else + { + FlxG.sound.music.pause(); + if (FreeplayState_Psych.vocals != null) FreeplayState_Psych.vocals.pause(); + if (FreeplayState_Psych.opponentVocals != null) FreeplayState_Psych.opponentVocals.pause(); + } + } } \ No newline at end of file diff --git a/source/funkin/ui/languages/EnUS.hx b/source/funkin/ui/languages/EnUS.hx index 00691460ff1..5c2b8b55181 100644 --- a/source/funkin/ui/languages/EnUS.hx +++ b/source/funkin/ui/languages/EnUS.hx @@ -16,6 +16,8 @@ class EnUS // Gameplay "score_text" => "Score: {1} | {2}: {3} | Rating: {4} | TPS: {5}/{6}", "score_text_instakill" => "Score: {1} | Rating: {2} | TPS: {3}/{4}", + "score_text_legacy" => "Score: {1} | Misses: {2} | Rating: {3}", + "score_text_instakill_legacy" => "Score: {1} | Rating: {2}", "botplay" => "Botplay", "misses" => "Misses", "combo_breaks" => "Combo Breaks", diff --git a/source/funkin/ui/languages/EsES.hx b/source/funkin/ui/languages/EsES.hx index 58700249b1b..a48554492ad 100644 --- a/source/funkin/ui/languages/EsES.hx +++ b/source/funkin/ui/languages/EsES.hx @@ -16,6 +16,8 @@ class EsES // Gameplay "score_text" => "Puntuación: {1} | {2}: {3} | Clasificación: {4}", "score_text_instakill" => "Puntuación: {1} | Clasificación: {2}", + "score_text_legacy" => "Puntuación: {1} | Fallos: {2} | Clasificación: {3}", + "score_text_instakill_legacy" => "Puntuación: {1} | Clasificación: {2}", "botplay" => "Automático", "misses" => "Fallos", "combo_breaks" => "Rupturas de Combo", diff --git a/source/funkin/ui/languages/EsLA.hx b/source/funkin/ui/languages/EsLA.hx index 13dc20b2aaf..8c031ad624f 100644 --- a/source/funkin/ui/languages/EsLA.hx +++ b/source/funkin/ui/languages/EsLA.hx @@ -30,6 +30,8 @@ class EsLA // Gameplay "score_text" => "Puntuación: {1} | {2}: {3} | Clasificación: {4} | TPS: {5}/{6}", "score_text_instakill" => "Fallas: {1} | Clasificación: {2} | TPS: {3}/{4}", + "score_text_legacy" => "Puntuación: {1} | Fallas: {2} | Clasificación: {3}", + "score_text_instakill_legacy" => "Puntuación: {1} | Clasificación: {2}", "botplay" => "Automático", "perfect_mode" => "M. Perfecto", "oponent_mode" => "M. Oponente", diff --git a/source/funkin/ui/languages/IdID.hx b/source/funkin/ui/languages/IdID.hx index ae0e9122070..424eeb51350 100644 --- a/source/funkin/ui/languages/IdID.hx +++ b/source/funkin/ui/languages/IdID.hx @@ -209,6 +209,7 @@ class IdID "setting_perfect_mode" => "Mode Sempurna", "setting_opponent_mode" => "Mode Lawan", "setting_no_drop_penalty" => "Tidak Ada Denda", + "setting_opponent_drain" => "Penguras Lawan", // Graphics Settings "setting_low_quality" => "Kualitas Rendah", @@ -294,6 +295,8 @@ class IdID "description_health_bar_opacity" => "Seberapa transparan seharusnya bar kesehatan dan ikon-ikon tersebut.", "setting_smooth_health_bar" => "Bar Kesehatan Halus", "description_smooth_health_bar" => "Jika dicentang, bar kesehatan akan bergerak dengan halus alih-alih bergerak secara tiba-tiba.", + "setting_health_bar_overflow" => "Bar Kesehatan Meluap", + "description_health_bar_overflow" => "Jika dicentang, ikon kesehatan dapat melampaui tepi bilah saat terjadi lonjakan kesehatan (gaya Mesin JS).", "setting_show_watermark" => "Tampilkan Watermark", "description_show_watermark" => "Jika dicentang, menampilkan watermark engine di pojok kanan bawah.", "setting_vsync" => "VSync", @@ -442,6 +445,7 @@ class IdID "description_hitbox_position" => "Jika dicentang, hitbox akan ditempatkan di bagian bawah layar, jika tidak, akan tetap di bagian atas.", "setting_dynamic_controls_color" => "Warna Kontrol Dinamis", "description_dynamic_controls_color" => "Jika dicentang, warna kontrol ponsel akan disetel ke warna note di pengaturan Anda.\n(berlaku hanya selama bermain game)", + "setting_device_perfomance_info" => "Informasi Kinerja Perangkat", "setting_storage_type" => "Jenis Penyimpanan", "storage_type_name_app_data" => "Data Aplikasi (Disarankan)", "storage_type_name_public" => "Penyimpanan Publik", diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx index 8f7ff24fd11..fb7ca79d3a1 100644 --- a/source/funkin/ui/mainmenu/MainMenuState.hx +++ b/source/funkin/ui/mainmenu/MainMenuState.hx @@ -21,7 +21,7 @@ class MainMenuState extends MusicBeatState public static var fnfVersion:String = '0.2.8'; public static var plusEngineBaseVersion:String = '1.2.7'; // Stable semantic version #if DEV_BUILD - public static var devUpdate:String = 'Build 450'; // Build xxx or Beta x + public static var devUpdate:String = 'Build 0'; // Build xxx or Beta x public static var plusEngineVersion:String = plusEngineBaseVersion + ' (' + devUpdate + ')'; #else public static var plusEngineVersion:String = plusEngineBaseVersion; @@ -57,8 +57,20 @@ class MainMenuState extends MusicBeatState var _analyzer:SpectralAnalyzer = null; var _analyzerLevels:Array = null; var _needsAnalyzerInit:Bool = false; - static inline var VIZ_BAR_COUNT:Int = 256; + #if mobile + static inline var VIZ_BAR_COUNT:Int = 96; + static inline var VIZ_UPDATE_INTERVAL:Float = 1 / 45; + #else + static inline var VIZ_BAR_COUNT:Int = 160; + static inline var VIZ_UPDATE_INTERVAL:Float = 1 / 60; + #end static inline var VIZ_BAR_MAX_H:Int = 240; + static inline var VIZ_BAR_FILL:Float = 0.62; + static inline var VIZ_MIN_H:Float = 2; + static inline var VIZ_SMOOTH_SPEED:Float = 18; + var _vizUpdateAccum:Float = 0.0; + var _vizTargetHeights:Array = []; + var _vizCurrentHeights:Array = []; #end static var showOutdatedWarning:Bool = true; @@ -93,16 +105,19 @@ class MainMenuState extends MusicBeatState #if funkin.vis _vizBars = new FlxTypedGroup(); var vizBarW:Int = Std.int(FlxG.width / VIZ_BAR_COUNT); + var vizDrawW:Int = Std.int(Math.max(1, vizBarW * VIZ_BAR_FILL)); + var vizOffsetX:Float = (vizBarW - vizDrawW) * 0.5; for(i in 0...VIZ_BAR_COUNT) { var vbar = new FlxSprite(); - vbar.makeGraphic(vizBarW - 1, VIZ_BAR_MAX_H, FlxColor.WHITE); - vbar.setGraphicSize(vizBarW - 1, 2); - vbar.updateHitbox(); - vbar.x = i * vizBarW; + vbar.makeGraphic(vizDrawW, VIZ_BAR_MAX_H, FlxColor.WHITE); + vbar.x = i * vizBarW + vizOffsetX; vbar.y = FlxG.height - 2; + vbar.scale.y = 2 / VIZ_BAR_MAX_H; vbar.alpha = 0.0; vbar.scrollFactor.set(); _vizBars.add(vbar); + _vizTargetHeights.push(VIZ_MIN_H); + _vizCurrentHeights.push(VIZ_MIN_H); } add(_vizBars); _needsAnalyzerInit = true; @@ -220,8 +235,9 @@ class MainMenuState extends MusicBeatState // Adjust dB range for better sensitivity _analyzer.minDb = -80; _analyzer.maxDb = -15; - #if !web - // Higher FFT size for better frequency resolution + #if mobile + _analyzer.fftN = 256; + #elseif !web _analyzer.fftN = 512; #end _needsAnalyzerInit = false; @@ -229,29 +245,37 @@ class MainMenuState extends MusicBeatState } if(_vizBars != null) { var vizBarW:Int = Std.int(FlxG.width / VIZ_BAR_COUNT); - if(_analyzer != null) { - _analyzerLevels = _analyzer.getLevels(_analyzerLevels); - for(i in 0..._vizBars.members.length) { - var vbar = _vizBars.members[i]; - if(vbar == null) continue; - var level:Float = (i < _analyzerLevels.length) ? _analyzerLevels[i].value : 0.0; - var h:Int = Std.int(Math.max(2, level * VIZ_BAR_MAX_H)); - vbar.setGraphicSize(vizBarW - 1, h); - vbar.updateHitbox(); - vbar.x = i * vizBarW; - vbar.y = FlxG.height - h; - vbar.alpha = 1.0; - } - } else { - for(i in 0..._vizBars.members.length) { - var vbar = _vizBars.members[i]; - if(vbar == null) continue; - vbar.setGraphicSize(vizBarW - 1, 2); - vbar.updateHitbox(); - vbar.y = FlxG.height - 2; - vbar.alpha = 1.0; + var vizOffsetX:Float = (vizBarW - Std.int(Math.max(1, vizBarW * VIZ_BAR_FILL))) * 0.5; + _vizUpdateAccum += elapsed; + + if (_vizUpdateAccum >= VIZ_UPDATE_INTERVAL) + { + _vizUpdateAccum = 0; + if(_analyzer != null) { + _analyzerLevels = _analyzer.getLevels(_analyzerLevels); + for(i in 0..._vizBars.members.length) { + var level:Float = (i < _analyzerLevels.length) ? _analyzerLevels[i].value : 0.0; + _vizTargetHeights[i] = Math.max(VIZ_MIN_H, level * VIZ_BAR_MAX_H); + } + } else { + for(i in 0..._vizBars.members.length) + _vizTargetHeights[i] = VIZ_MIN_H; } } + + var lerpFactor:Float = 1 - Math.exp(-elapsed * VIZ_SMOOTH_SPEED); + for(i in 0..._vizBars.members.length) { + var vbar = _vizBars.members[i]; + if(vbar == null) continue; + var curH:Float = _vizCurrentHeights[i]; + var targetH:Float = _vizTargetHeights[i]; + curH = FlxMath.lerp(targetH, curH, 1 - lerpFactor); + _vizCurrentHeights[i] = curH; + vbar.scale.y = curH / VIZ_BAR_MAX_H; + vbar.x = i * vizBarW + vizOffsetX; + vbar.y = FlxG.height - curH; + vbar.alpha = 1.0; + } } #end @@ -403,8 +427,10 @@ class MainMenuState extends MusicBeatState case 'story_mode': MusicBeatState.switchState(new funkin.ui.story.StoryMenuState()); case 'freeplay': - MusicBeatState.switchState(new funkin.ui.freeplay.FreeplayState()); - + if (ClientPrefs.data.newfreeplay) + MusicBeatState.switchState(new funkin.ui.freeplay.FreeplayState()); + else + MusicBeatState.switchState(new funkin.ui.freeplay.FreeplayState_Psych()); #if MODS_ALLOWED case 'mods': MusicBeatState.switchState(new funkin.modding.ModsMenuState()); diff --git a/source/funkin/ui/options/GameplayChangersSubstate.hx b/source/funkin/ui/options/GameplayChangersSubstate.hx index 994fcb34745..910bfdabe8b 100644 --- a/source/funkin/ui/options/GameplayChangersSubstate.hx +++ b/source/funkin/ui/options/GameplayChangersSubstate.hx @@ -71,6 +71,7 @@ class GameplayChangersSubstate extends MusicBeatSubstate optionsArray.push(new GameplayOption('Perfect Mode', 'perfect', BOOL, false)); optionsArray.push(new GameplayOption('Opponent Mode', 'opponentplay', BOOL, false)); optionsArray.push(new GameplayOption('No Drop Penalty', 'nodroppenalty', BOOL, false)); + optionsArray.push(new GameplayOption('Opponent Drain', 'opponentdrain', BOOL, false)); optionsArray.push(new GameplayOption('Botplay', 'botplay', BOOL, false)); } diff --git a/source/funkin/ui/options/GraphicsSettingsSubState.hx b/source/funkin/ui/options/GraphicsSettingsSubState.hx index d4c3e6bd712..99913897f0c 100644 --- a/source/funkin/ui/options/GraphicsSettingsSubState.hx +++ b/source/funkin/ui/options/GraphicsSettingsSubState.hx @@ -86,6 +86,12 @@ class GraphicsSettingsSubState extends BaseOptionsMenu addOption(option); #if !html5 //Apparently other framerates isn't correctly supported on Browser? Probably it has some V-Sync shit enabled by default, idk + var option:Option = new Option('VSync', + 'If checked, enables VSync. This may reduce tearing and cap frame pacing to the display refresh rate.\nYou may need to restart the game for full effect.', + 'vsync', + BOOL); + addOption(option); + var option:Option = new Option('Framerate', "Pretty self explanatory, isn't it?", 'framerate', diff --git a/source/funkin/ui/options/LegacySettingsSubState.hx b/source/funkin/ui/options/LegacySettingsSubState.hx index cad6ef9f9b0..bac82bccdee 100644 --- a/source/funkin/ui/options/LegacySettingsSubState.hx +++ b/source/funkin/ui/options/LegacySettingsSubState.hx @@ -35,6 +35,24 @@ class LegacySettingsSubState extends BaseOptionsMenu BOOL); addOption(option); + option = new Option('Vanilla Transition', + "If checked, uses the vanilla transition instead of the custom Plus Engine transition.", + 'vanillaTransition', + BOOL); + addOption(option); + + option = new Option('Use Psych Score Text', + "If checked, uses the original Psych Engine score text format in gameplay HUD.", + 'usePsychScoreText', + BOOL); + addOption(option); + + option = new Option('Use New Freeplay', + "If unchecked, uses the classic Psych Freeplay instead of the new Plus Engine Freeplay.", + 'newfreeplay', + BOOL); + addOption(option); + super(); } } diff --git a/source/funkin/ui/options/VisualsSettingsSubState.hx b/source/funkin/ui/options/VisualsSettingsSubState.hx index 84f6de8d1e6..fcecdec690b 100644 --- a/source/funkin/ui/options/VisualsSettingsSubState.hx +++ b/source/funkin/ui/options/VisualsSettingsSubState.hx @@ -131,6 +131,14 @@ class VisualsSettingsSubState extends BaseOptionsMenu BOOL); addOption(option); + #if android + var option:Option = new Option('EXPERIMENTAL Native Wavy Time Bar', + 'WARNING: (Feature Experimental) If enabled, uses the Android native wavy time bar and hides the engine time bar fill.', + 'useNativeWavyTimebar', + BOOL); + addOption(option); + #end + var option:Option = new Option('Flashing Lights', "Uncheck this if you're sensitive to flashing lights!", 'flashing', @@ -148,6 +156,12 @@ class VisualsSettingsSubState extends BaseOptionsMenu 'scoreZoom', BOOL); addOption(option); + + var option:Option = new Option('Time Text Bump', + 'If unchecked, disables the time text bump animation on beat.', + 'timeBump', + BOOL); + addOption(option); var option:Option = new Option('Abbreviate Score', 'If enabled, the score will be abbreviated (e.g. 10.00K, 1.00M).', @@ -172,7 +186,13 @@ class VisualsSettingsSubState extends BaseOptionsMenu 'smoothHealthBar', BOOL); addOption(option); - + + var option:Option = new Option('Health Bar Overflow', + 'If checked, health icons can go outside the bar edges on health spikes (JS Engine style).', + 'smoothHPBug', + BOOL); + addOption(option); + var option:Option = new Option('Show Watermark', 'If checked, shows the watermark on screen.', 'showWatermark', diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx index 9128b8a323f..d79da68bcdb 100644 --- a/source/funkin/ui/story/StoryMenuState.hx +++ b/source/funkin/ui/story/StoryMenuState.hx @@ -478,12 +478,12 @@ class StoryMenuState extends MusicBeatState sprDifficulty.loadGraphic(newImage); sprDifficulty.x = leftArrow.x + 60; sprDifficulty.x += (308 - sprDifficulty.width) / 3; - sprDifficulty.y = leftArrow.y - 15; + sprDifficulty.y = leftArrow.y; FlxTween.cancelTweensOf(sprDifficulty); sprDifficulty.alpha = 0; - sprDifficulty.y = leftArrow.y - sprDifficulty.height + 50; - FlxTween.tween(sprDifficulty, {y: leftArrow.y - 15, alpha: 1}, 0.07); + sprDifficulty.y = leftArrow.y - sprDifficulty.height; + FlxTween.tween(sprDifficulty, {y: leftArrow.y + 13, alpha: 1}, 0.07); } lastDifficultyName = diff; diff --git a/source/funkin/ui/transition/CustomFadeTransition.hx b/source/funkin/ui/transition/CustomFadeTransition.hx index af31ee53f4d..a859845213b 100644 --- a/source/funkin/ui/transition/CustomFadeTransition.hx +++ b/source/funkin/ui/transition/CustomFadeTransition.hx @@ -1,53 +1,117 @@ package funkin.ui.transition; +import flixel.FlxCamera; +import flixel.text.FlxText; +import flixel.text.FlxText.FlxTextBorderStyle; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxColor; import flixel.util.FlxGradient; -import funkin.ui.debug.TraceDisplay; - -#if LUA_ALLOWED -import funkin.modding.scripting.FunkinLua; -#end - -#if HSCRIPT_ALLOWED -import funkin.modding.scripting.HScript; -import crowplexus.hscript.Expr.Error as IrisError; -import crowplexus.hscript.Printer; -#end - -import funkin.modding.scripting.psychlua.LuaUtils; - -#if sys -import sys.FileSystem; -#end +import funkin.ui.mainmenu.MainMenuState; class CustomFadeTransition extends MusicBeatSubstate { public static var finishCallback:Void->Void; var isTransIn:Bool = false; + + public static var isTransitioning:Bool = false; + public static var currentTransition:CustomFadeTransition = null; + + var topDoor:FlxSprite; + var bottomDoor:FlxSprite; + var waterMark:FlxText; + var eventText:FlxText; + var iconSprite:FlxSprite; + var transBlack:FlxSprite; var transGradient:FlxSprite; var duration:Float; - - // CustomFadeTransition specific scripts - #if LUA_ALLOWED - public static var customTransitionLuaScript:FunkinLua = null; - #end - - #if HSCRIPT_ALLOWED - public static var customTransitionScript:HScript = null; - #end - - public function new(duration:Float, isTransIn:Bool) - { + + var topDoorTween:FlxTween; + var bottomDoorTween:FlxTween; + var textTween:FlxTween; + var iconTween:FlxTween; + + var isDestroyed:Bool = false; + var isClosing:Bool = false; + var activeTweens:Array = []; + var transitionId:String; + + static function generateId():String { + return 'transition_' + Date.now().getTime() + '_' + Math.floor(Math.random() * 1000); + } + + public static function initCustomTransitionScript():Void { + // Legacy hook preserved for compatibility. + } + + public static function cancelCurrentTransition():Void { + if (currentTransition != null && !currentTransition.isDestroyed) { + currentTransition.forceClose(); + } + + isTransitioning = false; + currentTransition = null; + finishCallback = null; + } + + function addTween(tween:FlxTween):FlxTween { + if (tween != null) { + activeTweens.push(tween); + } + return tween; + } + + public function new(duration:Float = 0.5, isTransIn:Bool) { this.duration = duration; this.isTransIn = isTransIn; + this.activeTweens = []; + this.transitionId = generateId(); + + if (currentTransition != null && currentTransition != this) { + cancelCurrentTransition(); + } + + currentTransition = this; + isTransitioning = true; + super(); } - override function create() - { - cameras = [FlxG.cameras.list[FlxG.cameras.list.length-1]]; + override function create() { + super.create(); + + if (currentTransition != this) { + forceClose(); + return; + } + + try { + var cam:FlxCamera = new FlxCamera(); + cam.bgColor = 0x00; + + #if mobile + cam.followLerp = 0; + cam.pixelPerfectRender = false; + #end + + FlxG.cameras.add(cam, false); + cameras = [FlxG.cameras.list[FlxG.cameras.list.length - 1]]; + + if (ClientPrefs.data.vanillaTransition) { + createVanillaTransition(); + } else { + createCustomTransition(); + } + } catch (e:Dynamic) { + forceUnlock(); + } + } + + function createVanillaTransition():Void { var width:Int = Std.int(FlxG.width / Math.max(camera.zoom, 0.001)); var height:Int = Std.int(FlxG.height / Math.max(camera.zoom, 0.001)); + transGradient = FlxGradient.createGradientFlxSprite(1, height, (isTransIn ? [0x0, FlxColor.BLACK] : [FlxColor.BLACK, 0x0])); transGradient.scale.x = width; transGradient.updateHitbox(); @@ -62,186 +126,348 @@ class CustomFadeTransition extends MusicBeatSubstate { transBlack.screenCenter(X); add(transBlack); - if(isTransIn) + if (isTransIn) transGradient.y = transBlack.y - transBlack.height; else transGradient.y = -transGradient.height; + } - super.create(); - - // Set 'this' in scripts for access to transition instance - setScriptInstance(); - - // Call scripts onCreate - callOnCustomTransitionScript('onCreate', [isTransIn, duration]); - } - - override function update(elapsed:Float) { - // Call script update - if returns Function_Stop, skip default transition behavior - var scriptResult = callOnCustomTransitionScript('onUpdate', [elapsed, isTransIn]); - - super.update(elapsed); + function createCustomTransition():Void { + var width:Int = FlxG.width; + var height:Int = FlxG.height; - // If script handled update completely, skip default behavior - if(scriptResult == LuaUtils.Function_Stop) return; - - final height:Float = FlxG.height * Math.max(camera.zoom, 0.001); - final targetPos:Float = transGradient.height + 50 * Math.max(camera.zoom, 0.001); - if(duration > 0) - transGradient.y += (height + targetPos) * elapsed / duration; - else - transGradient.y = (targetPos) * elapsed; + topDoor = new FlxSprite(); + topDoor.loadGraphic(Paths.image('ui/transition/transUp')); + topDoor.scrollFactor.set(); + topDoor.setGraphicSize(width, height); + topDoor.updateHitbox(); + topDoor.antialiasing = ClientPrefs.data.antialiasing; + + bottomDoor = new FlxSprite(); + bottomDoor.loadGraphic(Paths.image('ui/transition/transDown')); + bottomDoor.scrollFactor.set(); + bottomDoor.setGraphicSize(width, height); + bottomDoor.updateHitbox(); + bottomDoor.antialiasing = ClientPrefs.data.antialiasing; - if(isTransIn) - transBlack.y = transGradient.y + transGradient.height; + iconSprite = new FlxSprite(); + iconSprite.loadGraphic(Paths.image('loading_screen/icon')); + iconSprite.scrollFactor.set(); + iconSprite.scale.set(0.5, 0.5); + iconSprite.screenCenter(); + + waterMark = new FlxText(0, height - 140, 300, 'Plus Engine\nv${MainMenuState.plusEngineVersion}', 32); + waterMark.x = (width - waterMark.width) / 2; + waterMark.setFormat(Paths.font("aller.ttf"), 32, FlxColor.WHITE, CENTER, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); + waterMark.scrollFactor.set(); + waterMark.borderSize = 2; + + eventText = new FlxText(50, height - 60, 300, '', 28); + eventText.x = (width - eventText.width) / 2; + eventText.setFormat(Paths.font("aller.ttf"), 28, FlxColor.YELLOW, CENTER, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); + eventText.scrollFactor.set(); + eventText.borderSize = 2; + + if (isTransIn) + createTransitionIn(width, height); else - transBlack.y = transGradient.y - transBlack.height; + createTransitionOut(width, height); + } + + override function update(elapsed:Float):Void { + super.update(elapsed); + + if (ClientPrefs.data.vanillaTransition && transGradient != null) { + final height:Float = FlxG.height * Math.max(camera.zoom, 0.001); + final targetPos:Float = transGradient.height + 50 * Math.max(camera.zoom, 0.001); + if (duration > 0) + transGradient.y += (height + targetPos) * elapsed / duration; + else + transGradient.y = (targetPos) * elapsed; + + if (isTransIn) + transBlack.y = transGradient.y + transGradient.height; + else + transBlack.y = transGradient.y - transBlack.height; + + if (transGradient.y >= targetPos) + safeClose(); + } + } + + function createTransitionIn(width:Int, height:Int):Void { + topDoor.y = 0; + bottomDoor.y = 0; + iconSprite.alpha = 1; + + waterMark.alpha = 1; + eventText.alpha = 1; + eventText.text = Language.getPhrase('trans_opening', 'Opening...'); + + add(topDoor); + add(bottomDoor); + add(iconSprite); + add(waterMark); + add(eventText); + + try { + FlxG.sound.play(Paths.sound('FadeTransition'), 0.4); + } catch (e:Dynamic) {} + + topDoorTween = addTween(FlxTween.tween(topDoor, {y: -height}, duration, { + ease: FlxEase.expoInOut, + onStart: function(tween:FlxTween) { + if (isValidTransition() && eventText != null) + eventText.text = Language.getPhrase('trans_completed', 'Completed!'); + } + })); + + bottomDoorTween = addTween(FlxTween.tween(bottomDoor, {y: height}, duration, { + ease: FlxEase.expoInOut, + onComplete: function(tween:FlxTween) { + if (isValidTransition()) { + safeClose(); + } + } + })); + + textTween = addTween(FlxTween.tween(waterMark, {y: waterMark.y + 100, alpha: 0}, duration, { + ease: FlxEase.expoInOut + })); + + addTween(FlxTween.tween(eventText, {y: eventText.y + 100, alpha: 0}, duration, { + ease: FlxEase.expoInOut + })); + + iconTween = addTween(FlxTween.tween(iconSprite, {alpha: 0}, duration, { + ease: FlxEase.expoInOut + })); + } + + function createTransitionOut(width:Int, height:Int):Void { + topDoor.y = -height; + bottomDoor.y = height; + iconSprite.alpha = 0; + eventText.text = Language.getPhrase('trans_loading', 'Loading...'); + + var originalWaterMarkY = height - 140; + var originalEventTextY = height - 60; + waterMark.y = originalWaterMarkY + 100; + waterMark.alpha = 0; + eventText.y = originalEventTextY + 100; + eventText.alpha = 0; - if(transGradient.y >= targetPos) - { - // Call script before finishing - callOnCustomTransitionScript('onFinish', [isTransIn]); - + add(topDoor); + add(bottomDoor); + add(iconSprite); + add(waterMark); + add(eventText); + + textTween = addTween(FlxTween.tween(waterMark, {y: originalWaterMarkY, alpha: 1}, duration, { + ease: FlxEase.expoInOut + })); + + addTween(FlxTween.tween(eventText, {y: originalEventTextY, alpha: 1}, duration, { + ease: FlxEase.expoInOut + })); + + topDoorTween = addTween(FlxTween.tween(topDoor, {y: 0}, duration, { + ease: FlxEase.expoInOut + })); + + bottomDoorTween = addTween(FlxTween.tween(bottomDoor, {y: 0}, duration, { + ease: FlxEase.expoInOut, + onComplete: function(tween:FlxTween) { + if (!isValidTransition()) return; + + iconTween = addTween(FlxTween.tween(iconSprite, {alpha: 1}, 0.3, { + ease: FlxEase.sineIn, + onComplete: function(tween:FlxTween) { + if (isValidTransition()) { + safeFinishCallback(); + } + } + })); + } + })); + } + + function isValidTransition():Bool { + return !isDestroyed && !isClosing && currentTransition == this; + } + + function forceUnlock():Void { + if (currentTransition == this) { + isTransitioning = false; + currentTransition = null; + } + + finishCallback = null; + cancelAllTweens(); + + if (!isDestroyed) { + forceClose(); + } + } + + function forceClose():Void { + if (isDestroyed || isClosing) return; + + isClosing = true; + + if (currentTransition == this) { + isTransitioning = false; + currentTransition = null; + } + + cancelAllTweens(); + + try { close(); - if(finishCallback != null) finishCallback(); + } catch (e:Dynamic) {} + } + + function safeFinishCallback():Void { + if (!isValidTransition()) return; + + if (currentTransition == this) { + isTransitioning = false; + currentTransition = null; + } + + if (finishCallback != null) { + var callback = finishCallback; finishCallback = null; + try { + callback(); + } catch (e:Dynamic) {} } } - - public static function initCustomTransitionScript():Void - { - // Try to load Lua script first - #if (LUA_ALLOWED && sys) - if(customTransitionLuaScript == null) - { - #if MODS_ALLOWED - var luaPath:String = Paths.modFolders('scripts/CustomFadeTransition.lua'); - if(!FileSystem.exists(luaPath)) - luaPath = Paths.getSharedPath('scripts/CustomFadeTransition.lua'); - #else - var luaPath:String = Paths.getSharedPath('scripts/CustomFadeTransition.lua'); - #end - - if(FileSystem.exists(luaPath)) - { - trace('Loading CustomFadeTransition Lua Script from: $luaPath'); - customTransitionLuaScript = new FunkinLua(luaPath); - trace('CustomFadeTransition (Lua) initialized successfully'); - } - } - #end - - // Then load HScript - if(customTransitionScript != null) return; // Already initialized - - #if MODS_ALLOWED - var scriptPath:String = Paths.modFolders('scripts/CustomFadeTransition.hx'); - if(scriptPath == null || !FileSystem.exists(scriptPath)) - scriptPath = Paths.getSharedPath('scripts/CustomFadeTransition.hx'); - #else - var scriptPath:String = Paths.getSharedPath('scripts/CustomFadeTransition.hx'); - #end - - if(scriptPath == null || !FileSystem.exists(scriptPath)) - { - trace('No CustomFadeTransition script found'); - return; + + function safeClose():Void { + if (!isValidTransition()) return; + + isClosing = true; + + if (!ClientPrefs.data.vanillaTransition) { + if (currentTransition == this) { + isTransitioning = false; + currentTransition = null; + } } - - #if HSCRIPT_ALLOWED - try - { - trace('CustomFadeTransition: Loading script from: $scriptPath'); - customTransitionScript = new HScript(null, scriptPath, null, true); - - if(customTransitionScript == null) - { - trace('CustomFadeTransition: Failed to create HScript instance'); - return; - } - - // Parse and execute - customTransitionScript.parse(true); - customTransitionScript.execute(); - - trace('CustomFadeTransition script initialized successfully'); - } - catch(e:IrisError) - { - try { - var errorMsg = Printer.errorToString(e, false); - trace('CustomFadeTransition Script Error: $errorMsg'); - if(TraceDisplay.instance != null) - TraceDisplay.addHScriptError(errorMsg, scriptPath); - } catch(printerError:Dynamic) { - trace('CustomFadeTransition: Error while processing IrisError: $printerError'); + + cancelAllTweens(); + + try { + close(); + } catch (e:Dynamic) {} + } + + function cancelAllTweens():Void { + try { + for (tween in activeTweens) { + if (tween != null && !tween.finished) { + tween.cancel(); } } - catch(e:Dynamic) - { - trace('CustomFadeTransition Script Error (unexpected): $e'); - #if HSCRIPT_ALLOWED - if(TraceDisplay.instance != null) - TraceDisplay.addHScriptError('Unexpected error: $e', scriptPath); - #end - } - #end - } - - // Set this instance in scripts - public function setScriptInstance():Void - { - #if LUA_ALLOWED - if(customTransitionLuaScript != null) - { - customTransitionLuaScript.set('this', this); - } - #end - - #if HSCRIPT_ALLOWED - if(customTransitionScript != null) - { - customTransitionScript.set('this', this); - } - #end - } - - public function callOnCustomTransitionScript(funcToCall:String, args:Array = null):Dynamic - { - var returnVal:Dynamic = LuaUtils.Function_Continue; - - // Call on Lua script first - #if LUA_ALLOWED - if(customTransitionLuaScript != null) - { - var ret:Dynamic = customTransitionLuaScript.call(funcToCall, args != null ? args : []); - if(ret != null && ret != LuaUtils.Function_Continue) - returnVal = ret; - } - #end - - // Then call on HScript - #if HSCRIPT_ALLOWED - if(customTransitionScript != null && customTransitionScript.exists(funcToCall)) - { - try { - var callValue = customTransitionScript.call(funcToCall, args); - if(callValue != null && callValue.returnValue != null) - { - var myValue:Dynamic = callValue.returnValue; - if(myValue != LuaUtils.Function_Continue) - returnVal = myValue; - } + activeTweens = []; + + if (topDoorTween != null) { + topDoorTween.cancel(); + topDoorTween = null; + } + if (bottomDoorTween != null) { + bottomDoorTween.cancel(); + bottomDoorTween = null; + } + if (textTween != null) { + textTween.cancel(); + textTween = null; + } + if (iconTween != null) { + iconTween.cancel(); + iconTween = null; + } + } catch (e:Dynamic) {} + } + + override function close() { + if (isDestroyed) return; + + isDestroyed = true; + isClosing = true; + + if (currentTransition == this) { + isTransitioning = false; + currentTransition = null; + } + + try { + cancelAllTweens(); + + if (ClientPrefs.data.vanillaTransition && finishCallback != null) { + var callback = finishCallback; + finishCallback = null; + callback(); + } else { + finishCallback = null; } - catch(e:Dynamic) { - trace('CustomFadeTransition Script Error calling $funcToCall: $e'); - @:privateAccess - var fileName = customTransitionScript.origin != null ? customTransitionScript.origin : "CustomFadeTransition"; - TraceDisplay.addHScriptError('Runtime error in $funcToCall: $e', fileName); + + super.close(); + } catch (e:Dynamic) { + isTransitioning = false; + currentTransition = null; + } + } + + override function destroy() { + if (isDestroyed) return; + + isDestroyed = true; + isClosing = true; + + if (currentTransition == this) { + isTransitioning = false; + currentTransition = null; + } + + try { + cancelAllTweens(); + finishCallback = null; + + if (topDoor != null) { + topDoor.destroy(); + topDoor = null; + } + if (bottomDoor != null) { + bottomDoor.destroy(); + bottomDoor = null; + } + if (waterMark != null) { + waterMark.destroy(); + waterMark = null; + } + if (eventText != null) { + eventText.destroy(); + eventText = null; } + if (iconSprite != null) { + iconSprite.destroy(); + iconSprite = null; + } + if (transBlack != null) { + transBlack.destroy(); + transBlack = null; + } + if (transGradient != null) { + transGradient.destroy(); + transGradient = null; + } + + super.destroy(); + } catch (e:Dynamic) { + isTransitioning = false; + currentTransition = null; } - #end - - return returnVal; } } diff --git a/source/funkin/util/CoolUtil.hx b/source/funkin/util/CoolUtil.hx index e4eaa733210..8b6a54b177d 100644 --- a/source/funkin/util/CoolUtil.hx +++ b/source/funkin/util/CoolUtil.hx @@ -2,12 +2,20 @@ package funkin.util; import openfl.utils.Assets; import lime.utils.Assets as LimeAssets; +#if android +import lime.system.JNI; +#end #if cpp @:cppFileCode('#include ') #end class CoolUtil { + #if android + private static var showMessageBox_jni:Dynamic = null; + private static var showCrashScreen_jni:Dynamic = null; + #end + // Legacy update checker variables (forwarded to UpdateManager for compatibility) public static var hasUpdate(get, never):Bool; public static var latestVersion(get, never):String; @@ -197,11 +205,63 @@ class CoolUtil public static function showPopUp(message:String, title:String):Void { - /*#if android - AndroidTools.showAlertDialog(title, message, {name: "OK", func: null}, null); - #else*/ + #if android + try + { + if (showMessageBox_jni == null) + { + showMessageBox_jni = JNI.createStaticMethod( + 'com/leninasto/plusengine/PlusEngineExtension', + 'showMessageBox', + '(Ljava/lang/String;Ljava/lang/String;)V' + ); + } + + showMessageBox_jni(title, message); + } + catch (e:Dynamic) + { + trace('[CoolUtil] Native showPopUp failed: ' + e); + FlxG.stage.window.alert(message, title); + } + #else FlxG.stage.window.alert(message, title); - //#end + #end + } + + /** + * Show crash screen with full error details (Android only). + * This displays a native activity with the crash information. + * @param errorTitle Short error title + * @param errorMessage Brief error message + * @param stackTrace Full stack trace + */ + public static function showCrashScreen(errorTitle:String, errorMessage:String, stackTrace:String):Void + { + #if android + try + { + if (showCrashScreen_jni == null) + { + showCrashScreen_jni = JNI.createStaticMethod( + 'com/leninasto/plusengine/PlusEngineExtension', + 'showCrashScreen', + '(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V' + ); + } + + showCrashScreen_jni(errorTitle, errorMessage, stackTrace); + } + catch (e:Dynamic) + { + trace('[CoolUtil] Native showCrashScreen failed: ' + e); + // Fallback to popup + showPopUp('$errorTitle\n$errorMessage\n\n$stackTrace', "Critical Error"); + } + #else + // On non-Android platforms, use regular popup + showPopUp('$errorTitle\n$errorMessage\n\n$stackTrace', errorTitle); + #end } #if cpp diff --git a/source/funkin/util/CrashHandler.hx b/source/funkin/util/CrashHandler.hx index dbe274aa1ea..6bda9532cec 100644 --- a/source/funkin/util/CrashHandler.hx +++ b/source/funkin/util/CrashHandler.hx @@ -274,9 +274,14 @@ class CrashHandler saveErrorMessage('$m\n\n$stackLabel\n\n$systemReport'); #end - // Mensaje con link de ayuda + // Show crash screen on Android, popup on other platforms + #if android + var fullStackTrace = '$stackLabel\n\n$systemReport\n\n========================\nFor help, visit: $HELP_LINK'; + CoolUtil.showCrashScreen("Error!", m, fullStackTrace); + #else var errorMsg = '$m\n\n$stackLabel\n\n$systemReport\n\n========================\nNeed help? Visit:\n$HELP_LINK'; CoolUtil.showPopUp(errorMsg, "Error!"); + #end #if DISCORD_ALLOWED DiscordClient.shutdown(); #end lime.system.System.exit(1); @@ -314,9 +319,16 @@ class CrashHandler saveErrorMessage(errorLog); #end - // Message with help link + // Show crash screen on Android, popup on other platforms + #if android + var crashTitle = "Critical Error!"; + var crashMessage = message != null ? Std.string(message) : "Unknown error"; + var fullStackTrace = '$errorLog\n\n========================\nFor help, visit: $HELP_LINK'; + CoolUtil.showCrashScreen(crashTitle, crashMessage, fullStackTrace); + #else var errorMsg = '$errorLog\n\n========================\nNeed help? Visit:\n$HELP_LINK'; CoolUtil.showPopUp(errorMsg, "Critical Error!"); + #end #if DISCORD_ALLOWED DiscordClient.shutdown(); #end lime.system.System.exit(1); diff --git a/source/funkin/util/MemoryManager.hx b/source/funkin/util/MemoryManager.hx index b7af31fb459..f62fa4b755e 100644 --- a/source/funkin/util/MemoryManager.hx +++ b/source/funkin/util/MemoryManager.hx @@ -308,8 +308,6 @@ class MemoryManager */ public static function aggressiveCleanup():Void { - trace('[MemoryManager] Running AGGRESSIVE cleanup...'); - // Clear Paths caches Paths.clearUnusedMemory(); Paths.clearStoredMemory(); @@ -342,7 +340,6 @@ class MemoryManager #end } - trace('[MemoryManager] Aggressive cleanup complete'); } /** @@ -352,8 +349,6 @@ class MemoryManager */ public static function ultraCleanup():Void { - trace('[MemoryManager] ⚠️ ULTRA CLEANUP - This may cause temporary issues'); - // Run aggressive cleanup first aggressiveCleanup(); @@ -384,7 +379,6 @@ class MemoryManager #end } - trace('[MemoryManager] Ultra cleanup complete - ${Math.round(getMemoryUsage())}MB in use'); } /** @@ -426,9 +420,9 @@ class MemoryManager { var memoryMB:Float = getMemoryUsage(); if (memoryMB > 0) - trace('[MemoryManager] Current memory usage: ${Math.round(memoryMB)}MB'); + trace('Current memory usage: ${Math.round(memoryMB)}MB'); else - trace('[MemoryManager] Memory usage reporting not available on this platform'); + trace('Memory usage reporting not available on this platform'); } /** @@ -449,7 +443,6 @@ class MemoryManager if (PlayState.instance.camOther != null && PlayState.instance.camOther.filters != null) PlayState.instance.camOther.filters = []; - trace('[MemoryManager] Shaders cleared'); } /** @@ -463,7 +456,6 @@ class MemoryManager if (currentMemory > 0 && currentMemory > thresholdMB) { - trace('[MemoryManager] Threshold exceeded (${Math.round(currentMemory)}MB > ${thresholdMB}MB). Running cleanup...'); if (aggressiveMode) aggressiveCleanup(); else diff --git a/source/kotlin/FileManagerActivity.kt b/source/kotlin/FileManagerActivity.kt index 2454c942a9a..b3f4d0d8ce6 100644 --- a/source/kotlin/FileManagerActivity.kt +++ b/source/kotlin/FileManagerActivity.kt @@ -1,3 +1,27 @@ +/* + * Copyright (C) 2026 Lenin + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software. + * + * This Software may not be claimed as the original work of any other + * individual or entity. + * + * Attribution to the original author is appreciated, but is not required. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. + * + * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + package com.leninasto.plusengine import android.Manifest @@ -25,6 +49,8 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.NoteAdd +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -204,7 +230,7 @@ class FileManagerActivity : AppCompatActivity() { ) DropdownMenuItem( text = { androidx.compose.material3.Text(lang.newFile) }, - leadingIcon = { Icon(Icons.Default.NoteAdd, contentDescription = null) }, + leadingIcon = { Icon(Icons.AutoMirrored.Filled.NoteAdd, contentDescription = null) }, onClick = { showMenu = false; promptCreate(false) } ) } @@ -360,7 +386,7 @@ class FileManagerActivity : AppCompatActivity() { ext in videoExtensions -> Icons.Default.Movie ext in audioExtensions -> Icons.Default.MusicNote ext in textExtensions -> Icons.Default.Description - else -> Icons.Default.InsertDriveFile + else -> Icons.AutoMirrored.Filled.InsertDriveFile } } diff --git a/source/kotlin/FileUtil.kt b/source/kotlin/FileUtil.kt index f68dc8e6ec2..dd9b09c9645 100644 --- a/source/kotlin/FileUtil.kt +++ b/source/kotlin/FileUtil.kt @@ -1,3 +1,27 @@ +/* + * Copyright (C) 2026 Lenin + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software. + * + * This Software may not be claimed as the original work of any other + * individual or entity. + * + * Attribution to the original author is appreciated, but is not required. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. + * + * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + package com.leninasto.plusengine import android.content.Context diff --git a/source/kotlin/NativeCrashActivity.kt b/source/kotlin/NativeCrashActivity.kt new file mode 100644 index 00000000000..122f395aca3 --- /dev/null +++ b/source/kotlin/NativeCrashActivity.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2026 Lenin + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software. + * + * This Software may not be claimed as the original work of any other + * individual or entity. + * + * Attribution to the original author is appreciated, but is not required. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. + * + * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +package com.leninasto.plusengine + +import android.os.Bundle +import android.view.ViewGroup +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import android.content.ClipboardManager +import android.content.ClipData + +/** + * Simple activity to display native Java/Kotlin crash information. + */ +class NativeCrashActivity : AppCompatActivity() { + + companion object { + const val EXTRA_CRASH_TITLE = "crash_title" + const val EXTRA_CRASH_MESSAGE = "crash_message" + const val EXTRA_CRASH_TRACE = "crash_trace" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val crashTitle = intent.getStringExtra(EXTRA_CRASH_TITLE) ?: "Native Crash" + val crashMessage = intent.getStringExtra(EXTRA_CRASH_MESSAGE) ?: "No message" + val crashTrace = intent.getStringExtra(EXTRA_CRASH_TRACE) ?: "No stack trace" + + title = "Crash Report" + + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(16), dp(16), dp(16), dp(16)) + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + } + + val header = TextView(this).apply { + text = "${crashTitle}: ${crashMessage}" + textSize = 16f + setPadding(0, 0, 0, dp(12)) + } + + val scroll = ScrollView(this).apply { + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f) + } + + val traceText = TextView(this).apply { + text = crashTrace + textSize = 12f + setTextIsSelectable(true) + } + scroll.addView(traceText) + + val actions = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + + val copyButton = Button(this).apply { + text = "Copy" + setOnClickListener { + val clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Native Crash Trace", crashTrace)) + } + } + + val closeButton = Button(this).apply { + text = "Close" + setOnClickListener { finishAffinity() } + } + + actions.addView(copyButton, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + actions.addView(closeButton, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + + root.addView(header) + root.addView(scroll) + root.addView(actions) + + setContentView(root) + } + + private fun dp(value: Int): Int { + return (value * resources.displayMetrics.density).toInt() + } +} diff --git a/source/kotlin/NativeCrashHandler.kt b/source/kotlin/NativeCrashHandler.kt new file mode 100644 index 00000000000..80b9cb6c326 --- /dev/null +++ b/source/kotlin/NativeCrashHandler.kt @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2026 Lenin + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software. + * + * This Software may not be claimed as the original work of any other + * individual or entity. + * + * Attribution to the original author is appreciated, but is not required. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. + * + * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +package com.leninasto.plusengine + +import android.content.Intent +import android.util.Log +import android.os.Process +import org.haxe.extension.Extension +import java.io.PrintWriter +import java.io.StringWriter +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.system.exitProcess + +/** + * Captures Java/Kotlin crashes and opens a native crash activity. + */ +object NativeCrashHandler { + + private const val TAG = "NativeCrashHandler" + private const val CRASH_ACTIVITY_WAIT_MS = 350L + private val installed = AtomicBoolean(false) + + @JvmStatic + fun install() { + if (installed.getAndSet(true)) return + + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + try { + launchCrashActivity(throwable) + } catch (handlerError: Throwable) { + Log.e(TAG, "Failed to open crash activity", handlerError) + } finally { + try { + Thread.sleep(CRASH_ACTIVITY_WAIT_MS) + } catch (_: InterruptedException) { + } + + Process.killProcess(Process.myPid()) + exitProcess(10) + } + } + } + + @JvmStatic + fun showCrashActivity(throwable: Throwable) { + launchCrashActivity(throwable) + } + + @JvmStatic + fun showCrashActivityFromContext(title: String, message: String, stackTrace: String) { + try { + val context = Extension.mainActivity?.applicationContext ?: return + val intent = Intent(context, NativeCrashActivity::class.java).apply { + putExtra(NativeCrashActivity.EXTRA_CRASH_TITLE, title) + putExtra(NativeCrashActivity.EXTRA_CRASH_MESSAGE, message) + putExtra(NativeCrashActivity.EXTRA_CRASH_TRACE, stackTrace) + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TASK + ) + } + context.startActivity(intent) + } catch (e: Exception) { + Log.e(TAG, "Failed to show crash activity from context", e) + } + } + + private fun launchCrashActivity(throwable: Throwable) { + val activity = Extension.mainActivity ?: return + val stackTrace = throwable.toDetailedStackTrace() + + val context = activity.applicationContext + val intent = Intent(context, NativeCrashActivity::class.java).apply { + putExtra(NativeCrashActivity.EXTRA_CRASH_TITLE, throwable.javaClass.simpleName) + putExtra(NativeCrashActivity.EXTRA_CRASH_MESSAGE, throwable.message ?: "No message") + putExtra(NativeCrashActivity.EXTRA_CRASH_TRACE, stackTrace) + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TASK + ) + } + + context.startActivity(intent) + } + + private fun Throwable.toDetailedStackTrace(): String { + val writer = StringWriter() + val printer = PrintWriter(writer) + this.printStackTrace(printer) + printer.flush() + return writer.toString() + } +} diff --git a/source/kotlin/NativeUI.kt b/source/kotlin/NativeUI.kt index 65beb8114da..a018fb0caaa 100644 --- a/source/kotlin/NativeUI.kt +++ b/source/kotlin/NativeUI.kt @@ -1,3 +1,27 @@ +/* + * Copyright (C) 2026 Lenin + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software. + * + * This Software may not be claimed as the original work of any other + * individual or entity. + * + * Attribution to the original author is appreciated, but is not required. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. + * + * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + package com.leninasto.plusengine import android.content.Context @@ -9,6 +33,7 @@ import android.widget.Toast import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.google.android.material.color.MaterialColors +import android.util.Log /** * Native UI utilities for FNF: Plus Engine @@ -16,6 +41,8 @@ import com.google.android.material.color.MaterialColors */ object NativeUI { + private const val TAG = "NativeUI" + fun showDialog( context: Context, title: String, @@ -25,27 +52,73 @@ object NativeUI { negativeText: String? = null, onNegative: (() -> Unit)? = null ) { - val builder = MaterialAlertDialogBuilder(context) - .setTitle(title) - .setMessage(message) - .setPositiveButton(positiveText) { _, _ -> onPositive?.invoke() } - - negativeText?.let { - builder.setNegativeButton(it) { _, _ -> onNegative?.invoke() } + try { + // Check if context is valid (activity not destroyed) + if (context is android.app.Activity && context.isFinishing) { + Log.w(TAG, "Activity is finishing, falling back to Toast") + showToast(context, "$title: $message") + return + } + + val builder = MaterialAlertDialogBuilder(context) + .setTitle(title) + .setMessage(message) + .setPositiveButton(positiveText) { _, _ -> + try { + onPositive?.invoke() + } catch (e: Exception) { + Log.e(TAG, "Error in positive button callback", e) + } + } + + negativeText?.let { + builder.setNegativeButton(it) { _, _ -> + try { + onNegative?.invoke() + } catch (e: Exception) { + Log.e(TAG, "Error in negative button callback", e) + } + } + } + + // Prevent dialog from being cancelable during crashes + builder.setCancelable(false) + + builder.show() + } catch (e: Exception) { + Log.e(TAG, "Failed to show dialog", e) + // Fallback to Toast if dialog fails + try { + showToast(context, "$title: $message") + } catch (te: Exception) { + Log.e(TAG, "Even Toast failed", te) + } } - - builder.show() } fun showToast(context: Context, message: String) { - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + try { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Log.e(TAG, "Failed to show toast: $message", e) + } } fun showSnackbar(view: android.view.View, message: String, actionText: String? = null, onAction: (() -> Unit)? = null) { - val snack = Snackbar.make(view, message, Snackbar.LENGTH_LONG) - actionText?.let { - snack.setAction(it) { onAction?.invoke() } + try { + val snack = Snackbar.make(view, message, Snackbar.LENGTH_LONG) + actionText?.let { + snack.setAction(it) { + try { + onAction?.invoke() + } catch (e: Exception) { + Log.e(TAG, "Error in snackbar action", e) + } + } + } + snack.show() + } catch (e: Exception) { + Log.e(TAG, "Failed to show snackbar", e) } - snack.show() } } diff --git a/source/kotlin/PlusEngineExtension.kt b/source/kotlin/PlusEngineExtension.kt index c21065715d0..4aa27508602 100644 --- a/source/kotlin/PlusEngineExtension.kt +++ b/source/kotlin/PlusEngineExtension.kt @@ -1,7 +1,33 @@ +/* + * Copyright (C) 2026 Lenin + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software. + * + * This Software may not be claimed as the original work of any other + * individual or entity. + * + * Attribution to the original author is appreciated, but is not required. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. + * + * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + package com.leninasto.plusengine import android.content.Intent +import android.os.Bundle import org.haxe.extension.Extension +import com.leninasto.plusengine.WavyTimebarManager /** * JNI Extension for FNF: Plus Engine @@ -9,6 +35,16 @@ import org.haxe.extension.Extension */ class PlusEngineExtension : Extension() { + /** + * Called when the extension is created + * Initialize crash handler for Java/Kotlin exceptions + */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Install crash handler to catch Java/Kotlin exceptions + NativeCrashHandler.install() + } + companion object { /** @@ -106,5 +142,118 @@ class PlusEngineExtension : Extension() { val stat = android.os.StatFs(android.os.Environment.getExternalStorageDirectory().path) return stat.availableBlocksLong * stat.blockSizeLong } + + /** + * Show a native MD3 message box from Haxe. + */ + @JvmStatic + fun showMessageBox(title: String, message: String) { + val activity = Extension.mainActivity ?: return + activity.runOnUiThread { + NativeUI.showDialog( + context = activity, + title = title, + message = message, + positiveText = "OK" + ) + } + } + + /** + * Show crash activity with full error details. + * Called from Haxe CrashHandler when a critical error occurs. + * @param errorTitle Short error title (e.g., "Null Reference Error") + * @param errorMessage Brief error message + * @param stackTrace Full stack trace + */ + @JvmStatic + fun showCrashScreen(errorTitle: String, errorMessage: String, stackTrace: String) { + try { + val activity = Extension.mainActivity + if (activity == null || activity.isFinishing) { + // Fallback: try to show via application context + NativeCrashHandler.showCrashActivityFromContext(errorTitle, errorMessage, stackTrace) + } else { + // Create mock exception for crash display + val mockException = Exception("$errorTitle\n$errorMessage\n\n$stackTrace") + NativeCrashHandler.showCrashActivity(mockException) + } + } catch (e: Exception) { + android.util.Log.e("PlusEngineExtension", "Failed to show crash screen", e) + } + } + + // ========== WavyTimebar Methods ========== + + /** + * Initialize the Wavy Timebar overlay + * Call this once when PlayState is created + */ + @JvmStatic + fun initializeTimebar() { + WavyTimebarManager.initialize() + } + + /** + * Destroy timebar and free resources + * Call this when leaving PlayState + */ + @JvmStatic + fun destroyTimebar() { + WavyTimebarManager.destroy() + } + + /** + * Update timebar progress + * @param progress Value from 0.0 (start of song) to 1.0 (end of song) + */ + @JvmStatic + fun setTimebarProgress(progress: Float) { + WavyTimebarManager.setProgress(progress) + } + + /** + * Show timebar + */ + @JvmStatic + fun showTimebar() { + WavyTimebarManager.show() + } + + /** + * Hide timebar + */ + @JvmStatic + fun hideTimebar() { + WavyTimebarManager.hide() + } + + /** + * Set timebar visibility alpha + * @param alpha 0.0 = invisible, 1.0 = fully visible + */ + @JvmStatic + fun setTimebarAlpha(alpha: Float) { + WavyTimebarManager.setVisibility(alpha) + } + + /** + * Check if timebar is ready to use + * @return true if initialized and ready + */ + @JvmStatic + fun isTimebarReady(): Boolean { + return WavyTimebarManager.isReady() + } + + /** + * Configure native timebar layout. + * @param widthPercent Relative width in range [0.2, 1.0] + * @param yPx Top margin in physical pixels + */ + @JvmStatic + fun setTimebarLayout(widthPercent: Float, yPx: Float) { + WavyTimebarManager.setLayout(widthPercent, yPx) + } } } diff --git a/source/kotlin/WavyTimebarManager.kt b/source/kotlin/WavyTimebarManager.kt new file mode 100644 index 00000000000..612c031bba2 --- /dev/null +++ b/source/kotlin/WavyTimebarManager.kt @@ -0,0 +1,297 @@ +/* + * Copyright (C) 2026 Lenin + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software. + * + * This Software may not be claimed as the original work of any other + * individual or entity. + * + * Attribution to the original author is appreciated, but is not required. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. + * + * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +package com.leninasto.plusengine + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.view.animation.LinearInterpolator +import android.widget.FrameLayout +import org.haxe.extension.Extension +import java.util.concurrent.atomic.AtomicReference + +/** + * Manager for WavyProgressIndicator used as in-game timebar + * Provides JNI interface to control Material3 WavyProgressIndicator from Haxe + */ +object WavyTimebarManager { + + private class WavyProgressView(context: Context) : View(context) { + private val density = resources.displayMetrics.density + private val wavePath = Path() + + private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + color = Color.parseColor("#4D6A5ACD") + strokeCap = Paint.Cap.ROUND + } + + private val wavePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + color = Color.parseColor("#6A5ACD") + strokeCap = Paint.Cap.ROUND + } + + private var phase = 0f + private val phaseAnimator = ValueAnimator.ofFloat(0f, (Math.PI * 2).toFloat()).apply { + duration = 900L + repeatCount = ValueAnimator.INFINITE + interpolator = LinearInterpolator() + addUpdateListener { + phase = it.animatedValue as Float + invalidate() + } + } + + var progress: Float = 0f + set(value) { + field = value.coerceIn(0f, 1f) + invalidate() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (!phaseAnimator.isStarted) { + phaseAnimator.start() + } else if (phaseAnimator.isPaused) { + phaseAnimator.resume() + } + } + + override fun onDetachedFromWindow() { + if (phaseAnimator.isRunning) { + phaseAnimator.cancel() + } + super.onDetachedFromWindow() + } + + override fun onDraw(canvas: android.graphics.Canvas) { + super.onDraw(canvas) + + val centerY = height * 0.5f + val stroke = height * 0.68f + val amplitude = height * 0.2f + val waveLength = 36f * density + val startX = paddingLeft.toFloat() + stroke * 0.5f + val endX = width - paddingRight.toFloat() - stroke * 0.5f + val availableWidth = (endX - startX).coerceAtLeast(0f) + val progressWidth = availableWidth * progress + val progressEndX = (startX + progressWidth).coerceIn(startX, endX) + + trackPaint.strokeWidth = stroke + wavePaint.strokeWidth = stroke + + if (progressEndX < endX) { + canvas.drawLine(progressEndX, centerY, endX, centerY, trackPaint) + } + + if (progressWidth <= 1f) return + + wavePath.reset() + var x = startX + val step = 4f * density + wavePath.moveTo(x, centerY) + while (x <= progressEndX) { + val y = centerY + kotlin.math.sin((x / waveLength) * (Math.PI * 2) + phase).toFloat() * amplitude + wavePath.lineTo(x, y) + x += step + } + canvas.drawPath(wavePath, wavePaint) + } + } + + private var containerView: FrameLayout? = null + private var progressView: WavyProgressView? = null + private var currentProgress: Float = 0f + private var currentAlpha: Float = 0f + private var barWidthPercent: Float = 0.58f + private var barTopMarginPx: Int = 0 + private val isInitialized = AtomicReference(false) + + private fun clampWidthPercent(value: Float): Float { + return value.coerceIn(0.05f, 1f) + } + + private fun getBarWidthPx(screenWidthPx: Int): Int { + return (screenWidthPx * clampWidthPercent(barWidthPercent)).toInt() + } + + private fun applyLayoutOnUiThread(activity: android.app.Activity) { + val barHeightPx = (8 * activity.resources.displayMetrics.density).toInt() + val screenWidthPx = activity.resources.displayMetrics.widthPixels + val barWidthPx = getBarWidthPx(screenWidthPx) + + val view = progressView ?: return + view.layoutParams = FrameLayout.LayoutParams( + barWidthPx, + barHeightPx, + Gravity.TOP or Gravity.CENTER_HORIZONTAL + ).apply { + topMargin = barTopMarginPx + } + view.requestLayout() + } + + /** + * Initialize and add the timebar overlay to the activity + * Must be called from UI thread + */ + @JvmStatic + fun initialize() { + val activity = Extension.mainActivity ?: return + + if (isInitialized.getAndSet(true)) return // Already initialized + + activity.runOnUiThread { + try { + if (barTopMarginPx <= 0) { + barTopMarginPx = (8 * activity.resources.displayMetrics.density).toInt() + } + val barHeightPx = (8 * activity.resources.displayMetrics.density).toInt() + val screenWidthPx = activity.resources.displayMetrics.widthPixels + val barWidthPx = getBarWidthPx(screenWidthPx) + + containerView = FrameLayout(activity).apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + setBackgroundColor(Color.TRANSPARENT) + isClickable = false + isFocusable = false + } + + progressView = WavyProgressView(activity).apply { + layoutParams = FrameLayout.LayoutParams( + barWidthPx, + barHeightPx, + Gravity.TOP or Gravity.CENTER_HORIZONTAL + ).apply { + topMargin = barTopMarginPx + } + + progress = currentProgress.coerceIn(0f, 1f) + alpha = currentAlpha.coerceIn(0f, 1f) + } + + containerView?.addView(progressView) + + // Add to activity's content view + val rootView = activity.window.decorView.findViewById(android.R.id.content) + rootView.addView(containerView) + + } catch (e: Exception) { + e.printStackTrace() + isInitialized.set(false) + } + } + } + + /** + * Remove timebar from activity + */ + @JvmStatic + fun destroy() { + val activity = Extension.mainActivity ?: return + + activity.runOnUiThread { + containerView?.let { view -> + val parent = view.parent as? ViewGroup + parent?.removeView(view) + } + progressView = null + containerView = null + isInitialized.set(false) + } + } + + /** + * Update progress value (0.0 to 1.0) + * Thread-safe, can be called from any thread + * @param progress Value between 0.0 (empty) and 1.0 (full) + */ + @JvmStatic + fun setProgress(progress: Float) { + currentProgress = progress.coerceIn(0f, 1f) + val activity = Extension.mainActivity ?: return + activity.runOnUiThread { + progressView?.progress = currentProgress + } + } + + /** + * Set visibility alpha (0.0 = invisible, 1.0 = fully visible) + * @param alpha Value between 0.0 and 1.0 + */ + @JvmStatic + fun setVisibility(alpha: Float) { + currentAlpha = alpha.coerceIn(0f, 1f) + val activity = Extension.mainActivity ?: return + activity.runOnUiThread { + progressView?.alpha = currentAlpha + } + } + + /** + * Show timebar (fade in) + */ + @JvmStatic + fun show() { + setVisibility(1f) + } + + /** + * Hide timebar (fade out) + */ + @JvmStatic + fun hide() { + setVisibility(0f) + } + + /** + * Check if timebar is initialized + */ + @JvmStatic + fun isReady(): Boolean { + return isInitialized.get() && progressView != null + } + + @JvmStatic + fun setLayout(widthPercent: Float, yPx: Float) { + barWidthPercent = clampWidthPercent(widthPercent) + barTopMarginPx = kotlin.math.max(0, yPx.toInt()) + + val activity = Extension.mainActivity ?: return + activity.runOnUiThread { + applyLayoutOnUiThread(activity) + } + } +} diff --git a/source/kotlin/components/DropDown.kt b/source/kotlin/components/DropDown.kt new file mode 100644 index 00000000000..b77a656dc3e --- /dev/null +++ b/source/kotlin/components/DropDown.kt @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2026 Lenin + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software. + * + * This Software may not be claimed as the original work of any other + * individual or entity. + * + * Attribution to the original author is appreciated, but is not required. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. + * + * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + +package com.leninasto.plusengine.components + +import android.text.Editable +import android.text.TextWatcher +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.LinearLayout +import android.widget.ListView +import androidx.appcompat.app.AlertDialog +import com.leninasto.plusengine.NativeCrashHandler +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import org.haxe.extension.Extension +import org.json.JSONArray +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +/** + * Native Android MD3 dropdown helper. + * + * This class exposes static methods for JNI so Haxe can open + * a native single-choice popup and poll the selected item index. + */ +object DropDown { + + const val NO_SELECTION: Int = -1 + const val CANCELED: Int = -2 + + private val pendingSelection: AtomicInteger = AtomicInteger(NO_SELECTION) + private val dialogVisible: AtomicBoolean = AtomicBoolean(false) + + @JvmStatic + fun showDropDown(title: String?, itemsJson: String, selectedIndex: Int): Boolean { + val activity = Extension.mainActivity ?: return false + val items = parseItems(itemsJson) + if (items.isEmpty()) return false + + activity.runOnUiThread { + try { + if (dialogVisible.get()) return@runOnUiThread + + dialogVisible.set(true) + val safeSelectedIndex = selectedIndex.coerceIn(0, items.lastIndex) + val displayTitle = if (title.isNullOrBlank()) "Select option" else title + val filteredItems = items.toMutableList() + val filteredIndices = items.indices.toMutableList() + + val listView = ListView(activity).apply { + choiceMode = ListView.CHOICE_MODE_SINGLE + } + val adapter = ArrayAdapter(activity, android.R.layout.simple_list_item_single_choice, filteredItems) + listView.adapter = adapter + + val searchInput = TextInputEditText(activity) + val searchLayout = TextInputLayout(activity).apply { + hint = "Search" + addView( + searchInput, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + ) + } + + val container = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(20), dp(8), dp(20), dp(0)) + addView(searchLayout, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + addView( + listView, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(280)) + ) + } + + var dialogRef: AlertDialog? = null + + fun refreshList(query: String?) { + val trimmed = query?.trim()?.lowercase() ?: "" + filteredItems.clear() + filteredIndices.clear() + + for (index in items.indices) { + val item = items[index] + if (trimmed.isEmpty() || item.lowercase().contains(trimmed)) { + filteredItems.add(item) + filteredIndices.add(index) + } + } + + adapter.notifyDataSetChanged() + val selectedFilteredIndex = filteredIndices.indexOf(safeSelectedIndex) + if (selectedFilteredIndex >= 0) { + listView.setItemChecked(selectedFilteredIndex, true) + listView.setSelection(selectedFilteredIndex) + } else { + listView.clearChoices() + } + } + + searchInput.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + refreshList(s?.toString()) + } + override fun afterTextChanged(s: Editable?) {} + }) + + listView.setOnItemClickListener { _, _, position, _ -> + if (position >= 0 && position < filteredIndices.size) { + pendingSelection.set(filteredIndices[position]) + dialogRef?.dismiss() + } + } + + dialogRef = MaterialAlertDialogBuilder(activity) + .setTitle(displayTitle) + .setView(container) + .setNegativeButton(android.R.string.cancel) { _, _ -> + pendingSelection.set(CANCELED) + } + .setOnCancelListener { + pendingSelection.set(CANCELED) + } + .setOnDismissListener { + dialogVisible.set(false) + } + .show() + + refreshList(null) + } catch (throwable: Throwable) { + dialogVisible.set(false) + pendingSelection.set(CANCELED) + NativeCrashHandler.showCrashActivity(throwable) + } + } + + return true + } + + @JvmStatic + fun pollSelection(): Int { + val currentValue = pendingSelection.get() + if (currentValue == NO_SELECTION) return NO_SELECTION + + pendingSelection.set(NO_SELECTION) + return currentValue + } + + @JvmStatic + fun isDialogVisible(): Boolean { + return dialogVisible.get() + } + + private fun parseItems(itemsJson: String): List { + return try { + val json = JSONArray(itemsJson) + buildList(json.length()) { + for (index in 0 until json.length()) { + add(json.optString(index, "")) + } + } + } catch (_: Throwable) { + emptyList() + } + } + + private fun dp(value: Int): Int { + val activity = Extension.mainActivity ?: return value + return (value * activity.resources.displayMetrics.density).toInt() + } +} diff --git a/source/kotlin/include_android.xml b/source/kotlin/include_android.xml index 6f0e86ff6d4..251713a1455 100644 --- a/source/kotlin/include_android.xml +++ b/source/kotlin/include_android.xml @@ -15,6 +15,10 @@