package funkin.ui; import flixel.FlxSprite; import flixel.util.FlxColor; import flixel.effects.FlxFlicker; import flixel.group.FlxGroup.FlxTypedGroup; import flixel.util.FlxSignal.FlxTypedSignal; import funkin.audio.FunkinSound; import funkin.util.TouchUtil; import funkin.util.SwipeUtil; import funkin.ui.Page.PageName; import flixel.tweens.FlxEase; import funkin.util.HapticUtil; import flixel.tweens.FlxTween; @:nullSafety class MenuTypedList extends FlxTypedGroup { // Pause input variable public static var pauseInput:Bool = false; public var selectedIndex(default, null):Int = 0; public var selectedItem(get, never):T; /** Called when a new item is highlighted */ public var onChange(default, null) = new FlxTypedSignalVoid>(); /** Called when an item is accepted */ public var onAcceptPress(default, null) = new FlxTypedSignalVoid>(); /** The navigation control scheme to use */ public var navControls:NavControls; /** Set to false to disable nav control */ public var enabled:Bool = true; /** */ public var wrapMode:WrapMode = Both; var byName = new Map(); /** Set to true, internally to disable controls, without affecting vars like `enabled` */ public var busy:Bool = false; // bit awkward because BACK is also a menu control and this doesn't affect that // #if mobile /** touchBuddy over here helps with the touch input! Because overlap for touch does not account for the graphic, only the hitbox. * And, `FlxG.pixelPerfectOverlap` uses two FlxSprites, so we can't use the `FlxTouch` object */ public var touchBuddy:FlxSprite; // #end /** Only used in Options, basically acts the same as OptionsState's `currentName`, it's the current name of the current page in OptionsState. * Why is it needed? Because touch control's a bitch. Thats why. */ public var currentPage:Null; // Helper variable var _isMainMenuState:Bool = false; public function new(navControls:NavControls = Vertical, ?wrapMode:WrapMode) { this.navControls = navControls; if (wrapMode != null) this.wrapMode = wrapMode; else { this.wrapMode = switch (navControls) { case Horizontal: Horizontal; case Vertical: Vertical; default: Both; } } touchBuddy = new FlxSprite().makeGraphic(10, 10); _isMainMenuState = Std.isOfType(FlxG.state, funkin.ui.mainmenu.MainMenuState); super(); } public function addItem(name:String, item:T):T { if (length == selectedIndex) item.select(); byName[name] = item; return add(item); } public function resetItem(oldName:String, newName:String, ?callback:Void->Void):Null { var item = byName[oldName]; if (item == null) throw 'No item named: $oldName'; byName.remove(oldName); byName[newName] = item; item.setItem(newName, callback); return item; } override function update(elapsed:Float) { super.update(elapsed); if (enabled && !busy && !pauseInput) updateControls(); } inline function updateControls():Void { var controls = PlayerSettings.player1.controls; var wrapX = wrapMode.match(Horizontal | Both); var wrapY = wrapMode.match(Vertical | Both); var newIndex = 0; // Define unified input handlers final inputUp:Bool = controls.UI_UP_P || (!_isMainMenuState && SwipeUtil.swipeUp); final inputDown:Bool = controls.UI_DOWN_P || (!_isMainMenuState && SwipeUtil.swipeDown); final inputLeft:Bool = controls.UI_LEFT_P || (!_isMainMenuState && SwipeUtil.swipeLeft); final inputRight:Bool = controls.UI_RIGHT_P || (!_isMainMenuState && SwipeUtil.swipeRight); // Keepin' these for keyboard/controller support on mobile platforms newIndex = switch (navControls) { case Vertical: navList(inputUp, inputDown, wrapY); case Horizontal: navList(inputLeft, inputRight, wrapX); case Both: navList(inputLeft || inputUp, inputRight || inputDown, !wrapMode.match(None)); case Columns(num): navGrid(num, inputLeft, inputRight, wrapX, inputUp, inputDown, wrapY); case Rows(num): navGrid(num, inputUp, inputDown, wrapY, inputLeft, inputRight, wrapX); }; #if FEATURE_TOUCH_CONTROLS // Update touch position if (TouchUtil.pressed) { touchBuddy.setPosition(TouchUtil.touch.x, TouchUtil.touch.y); } if (funkin.mobile.input.ControlsHandler.usingExternalInputDevice) { if (newIndex != selectedIndex) { FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4); selectItem(newIndex); } } else if (TouchUtil.pressed) { for (i in 0...members.length) { final item = members[i]; final menuCamera = FlxG.cameras.list[1]; final itemOverlaps:Bool = !_isMainMenuState && TouchUtil.overlaps(item, menuCamera); final itemPixelOverlap:Bool = _isMainMenuState && FlxG.pixelPerfectOverlap(touchBuddy, item, 0); final isTouchingItem:Bool = itemOverlaps || itemPixelOverlap; if (item.available && isTouchingItem && TouchUtil.justPressed) { var prevIndex:Int = selectedIndex; if (!_isMainMenuState && selectedIndex != i) { newIndex = i; break; } else { FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4); selectItem(i); } if (_isMainMenuState) { if (prevIndex == i) { FlxTween.cancelTweensOf(item); item.scale.set(1.1, 1.1); FlxTween.tween(item.scale, {x: 1, y: 1}, 0.3, {ease: FlxEase.backOut}); HapticUtil.vibrate(0, 0.05, 1); accept(); } else { FlxTween.cancelTweensOf(item); item.scale.set(0.94, 0.94); FlxTween.tween(item.scale, {x: 1, y: 1}, 0.3, {ease: FlxEase.backOut}); HapticUtil.vibrate(0, 0.01, 0.5); } } else { accept(); } break; } } } if (newIndex != selectedIndex && !_isMainMenuState) { FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4); selectItem(newIndex); } #else if (newIndex != selectedIndex) { FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4); selectItem(newIndex); } #end // Todo: bypass popup blocker on firefox if (controls.ACCEPT) accept(); return; } function navAxis(index:Int, size:Int, prev:Bool, next:Bool, allowWrap:Bool):Int { if (prev == next) return index; if (prev) { if (index > 0) index--; else if (allowWrap) index = size - 1; } else { if (index < size - 1) index++; else if (allowWrap) index = 0; } return index; } /** * Controls navigation on a linear list of items such as Vertical. * @param prev * @param next * @param allowWrap */ inline function navList(prev:Bool, next:Bool, allowWrap:Bool) { return navAxis(selectedIndex, length, prev, next, allowWrap); } /** * Controls navigation on a grid * @param latSize The size of the fixed axis of the grid, or the "lateral axis" * @param latPrev Whether the 'prev' key is pressed along the fixed-lengthed axis. eg: "left" in Column mode * @param latNext Whether the 'next' key is pressed along the fixed-lengthed axis. eg: "right" in Column mode * @param prev Whether the 'prev' key is pressed along the variable-lengthed axis. eg: "up" in Column mode * @param next Whether the 'next' key is pressed along the variable-lengthed axis. eg: "down" in Column mode * @param allowWrap unused */ function navGrid(latSize:Int, latPrev:Bool, latNext:Bool, latAllowWrap:Bool, prev:Bool, next:Bool, allowWrap:Bool):Int { // The grid length along the variable-length axis var size = Math.ceil(length / latSize); // The selected position along the variable-length axis var index = Math.floor(selectedIndex / latSize); // The selected position along the fixed axis var latIndex = selectedIndex % latSize; latIndex = navAxis(latIndex, latSize, latPrev, latNext, latAllowWrap); index = navAxis(index, size, prev, next, allowWrap); return Std.int(Math.min(length - 1, index * latSize + latIndex)); } public function accept():Void { var menuItem:T = members[selectedIndex]; if (!menuItem.available) return; onAcceptPress.dispatch(menuItem); if (menuItem.fireInstantly) menuItem.callback(); else { busy = true; FunkinSound.playOnce(Paths.sound('confirmMenu')); FlxFlicker.flicker(menuItem, 1, 0.06, true, false, function(_) { busy = false; menuItem.callback(); }); } } public function cancelAccept():Void { FlxFlicker.stopFlickering(members[selectedIndex]); busy = false; } /** * Selects an item in the list. If the item is not available, it will select the next available item. * @param index The index of the item to select. */ public function selectItem(index:Int):Void { members[selectedIndex].idle(); if (!members[index].available) { if (index < selectedIndex) { final newIndex:Int = (index - 1 < 0) ? index + 1 : index - 1; selectItem(newIndex); return; } else if (index > selectedIndex) { final newIndex:Int = (index + 1 > members.length) ? index - 1 : index + 1; selectItem(newIndex); return; } } selectedIndex = index; var selectedMenuItem:T = members[selectedIndex]; selectedMenuItem.select(); onChange.dispatch(selectedMenuItem); } public function has(name:String):Bool { return byName.exists(name); } public function getItem(name:String):Null { return byName[name]; } override function destroy():Void { super.destroy(); byName.clear(); onChange.removeAll(); onAcceptPress.removeAll(); } inline function get_selectedItem():T { return members[selectedIndex]; } } @:nullSafety class MenuListItem extends FlxSprite { public var callback:Void->Void; public var name:String; public var available:Bool; /** * Set to true for things like opening URLs otherwise, it may it get blocked. */ public var fireInstantly:Bool = false; public var selected(get, never):Bool; function get_selected() return alpha == 1.0; public function new(x = 0.0, y = 0.0, name:String, callback, available:Bool = true) { super(x, y); // This is just here to satisfy the null-safety checker // setData still needs to be called since other classes may override it this.name = name; this.callback = callback; this.available = available; setData(name, callback, available); idle(); } function setData(name:String, ?callback:Void->Void, available:Bool):Void { this.name = name; if (callback != null) this.callback = callback; this.available = available; } /** * Calls setData and resets/redraws the state of the item * @param name the label. * @param callback Unchanged if null. */ public function setItem(name:String, ?callback:Void->Void):Void { setData(name, callback, available); if (selected) select(); else idle(); } public function idle():Void { alpha = 0.6; } public function select():Void { alpha = 1.0; } } @:nullSafety class MenuTypedItem extends MenuListItem { public var label(default, set):Null; public function new(x = 0.0, y = 0.0, label:T, name:String, callback, available:Bool = true) { super(x, y, name, callback, available); // set label after super otherwise setters fuck up this.label = label; } /** * Use this when you only want to show the label */ public function setEmptyBackground() { var oldWidth = width; var oldHeight = height; makeGraphic(1, 1, 0x0); width = oldWidth; height = oldHeight; } function set_label(value:Null):Null { if (value != null) { value.x = x; value.y = y; value.alpha = alpha; } return this.label = value; } override function update(elapsed:Float) { super.update(elapsed); if (label != null) label.update(elapsed); } override function draw() { super.draw(); if (label != null) { label.cameras = cameras; label.scrollFactor.copyFrom(scrollFactor); label.draw(); } } override function set_alpha(value:Float):Float { super.set_alpha(value); if (label != null) label.alpha = alpha; return alpha; } override function set_x(value:Float):Float { super.set_x(value); if (label != null) label.x = x; return x; } override function set_y(Value:Float):Float { super.set_y(Value); if (label != null) label.y = y; return y; } } enum NavControls { Horizontal; Vertical; Both; Columns(num:Int); Rows(num:Int); } enum WrapMode { Horizontal; Vertical; Both; None; }