Skip to content

Commit

Permalink
Merge pull request #144 from XuYicong/fix/random-zoom
Browse files Browse the repository at this point in the history
Fix random zoom in zoom out
  • Loading branch information
Depal1 committed Jun 4, 2024
2 parents 7c16782 + e212ed7 commit 4f3713b
Show file tree
Hide file tree
Showing 4 changed files with 155 additions and 32 deletions.
20 changes: 20 additions & 0 deletions PlayTools/Controls/ActionDispatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ public class ActionDispatcher {

static public var cursorHideNecessary = true

/**
Lift off (release) all actions' touch points.
Would be called during mode switching where target mode shouldn't have any touch remain.
*/
static public func invalidateActions() {
for action in actions {
// This is just a rescue feature, in case any key stuck pressed for any reason
Expand All @@ -149,6 +153,22 @@ public class ActionDispatcher {
}
}

/**
Lift off (release) touch points of actions that moves freely under control of the user. (e.g. camera control action)
Would be called during every mode switching where `invalidateActions` is not called.
In such scenario button-type actions are not released, because users may continue using them across different modes.
(e.g. holding W while unhiding cursor to click something)
But non-button-type actions (e.g. camera control action, fake mouse action) are unlikely used across modes.
If they're not released, they would interfere with and ruin the game's camera control (becomes random zoom in zoom out)
*/
static public func invalidateNonButtonActions() {
for action in actions
where !(action as? ButtonAction != nil || action is JoystickAction){
action.invalidate()
}
}

static public func getDispatchPriority(key: String) -> ActionDispatchPriority? {
if let priority = directionPadHandlers.firstIndex(where: { handlers in
handlers.contains(where: { handler in
Expand Down
93 changes: 81 additions & 12 deletions PlayTools/Controls/Backend/Action/PlayAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,12 @@ class DraggableButtonAction: ButtonAction {
AKInterface.shared!.hideCursor()
}
} else {
ActionDispatcher.unregister(key: KeyCodeNames.mouseMove)
Toucher.touchcam(point: releasePoint, phase: UITouch.Phase.ended, tid: &id)
if !mode.cursorHidden() {
AKInterface.shared!.unhideCursor()
if id == nil {
ActionDispatcher.unregister(key: KeyCodeNames.mouseMove)
if !mode.cursorHidden() {
AKInterface.shared!.unhideCursor()
}
}
}
}
Expand Down Expand Up @@ -333,6 +335,7 @@ class SwipeAction: Action {
// if should wait before beginning next touch
var cooldown = false
var lastCounter = 0
var shouldEdgeReset = false

func checkEnded() {
if self.counter == self.lastCounter {
Expand All @@ -345,6 +348,34 @@ class SwipeAction: Action {
self.lastCounter = self.counter
}

private func checkXYOutOfWindow(coordX: CGFloat, coordY: CGFloat) -> Bool {
return coordX < 0 || coordY < 0 || coordX > screen.width || coordY > screen.height
}

/**
get a multiplier to current velocity, so as to make the predicted coordinate inside window
*/
private func getVelocityScaler(predictX: CGFloat, predictY: CGFloat,
nowX: CGFloat, nowY: CGFloat) -> CGFloat {
var scaler = 1.0
if predictX < 0 {
let scale = (0 - nowX) / (predictX - nowX)
scaler = min(scaler, scale)
} else if predictX > screen.width {
let scale = (screen.width - nowX) / (predictX - nowX)
scaler = min(scaler, scale)
}

if predictY < 0 {
let scale = (0 - nowY) / (predictY - nowY)
scaler = min(scaler, scale)
} else if predictY > screen.height {
let scale = (screen.height - nowY) / (predictY - nowY)
scaler = min(scaler, scale)
}
return scaler
}

public func move(from: () -> CGPoint?, deltaX: CGFloat, deltaY: CGFloat) {
if id == nil {
if cooldown {
Expand All @@ -355,25 +386,60 @@ class SwipeAction: Action {
counter = 0
Toucher.touchcam(point: location, phase: UITouch.Phase.began, tid: &id)
timer.resume()
} else {
if shouldEdgeReset {
doLiftOff()
return
}
// 1. Put location update after touch action, so that final `end` touch has different location
// 2. If `began` touched, do not `move` touch at the same time, otherwise the two may conflict
Toucher.touchcam(point: self.location, phase: UITouch.Phase.moved, tid: &id)
}
// Scale movement down, so that an edge reset won't cause a too short touch sequence
var scaledDeltaX = deltaX
var scaledDeltaY = deltaY
// A scroll must have this number of touch events to get inertia
let minTotalCounter = 16
if counter < minTotalCounter {
// Suppose the touch velocity doesn't change
let predictX = self.location.x + CGFloat((minTotalCounter - counter)) * deltaX
let predictY = self.location.y - CGFloat((minTotalCounter - counter)) * deltaY
if checkXYOutOfWindow(coordX: predictX, coordY: predictY) {
// Velocity needs scale down
let scaler = getVelocityScaler(predictX: predictX, predictY: predictY,
nowX: self.location.x, nowY: self.location.y)
scaledDeltaX *= scaler
scaledDeltaY *= scaler
}
}
// count touch duration
counter += 1
self.location.x += deltaX
self.location.y -= deltaY

Toucher.touchcam(point: self.location, phase: UITouch.Phase.moved, tid: &id)
self.location.x += scaledDeltaX
self.location.y -= scaledDeltaY
// Check if new location is out of window (position overflows)
// May fail in some games if point moves out of window
// If next touch is predicted out of window then this lift off instead
if checkXYOutOfWindow(coordX: self.location.x + scaledDeltaX,
coordY: self.location.y - scaledDeltaY) {
// Wait until next event to lift off, so as to maintain smooth scrolling speed
shouldEdgeReset = true
}
}

public func doLiftOff() {
if id == nil {
return
}
Toucher.touchcam(point: self.location, phase: UITouch.Phase.ended, tid: &id)
timer.suspend()
delay(0.02) {
self.cooldown = false
// Touch might somehow fail to end
if id == nil {
timer.suspend()
delay(0.02) {
self.cooldown = false
}
cooldown = true
shouldEdgeReset = false
}
cooldown = true
}

func invalidate() {
Expand Down Expand Up @@ -408,8 +474,10 @@ class FakeMouseAction: Action {
// DispatchQueue.main.async {
// Toast.showHint(title: " lift Fake mouse", text: ["\(self.pos)"])
// }
ActionDispatcher.unregister(key: KeyCodeNames.fakeMouse)
Toucher.touchcam(point: pos, phase: UITouch.Phase.ended, tid: &id)
if id == nil {
ActionDispatcher.unregister(key: KeyCodeNames.fakeMouse)
}
}

func movementHandler(xValue: CGFloat, yValue: CGFloat) {
Expand All @@ -419,6 +487,7 @@ class FakeMouseAction: Action {
}

func invalidate() {
ActionDispatcher.unregister(key: KeyCodeNames.fakeMouse)
Toucher.touchcam(point: pos ?? CGPoint(x: 10, y: 10),
phase: UITouch.Phase.ended, tid: &self.id)
}
Expand Down
10 changes: 10 additions & 0 deletions PlayTools/Controls/Frontend/ControlMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,23 @@ public class ControlMode: Equatable {

if mode == .OFF || mode == .EDITOR {
ActionDispatcher.invalidateActions()
} else {
// In case any touch point failed to release
// (might because of system glitch)
// Work around random zoom in zoom out
ActionDispatcher.invalidateNonButtonActions()
}

AKInterface.shared!.unhideCursor()
} else {
NotificationCenter.default.post(name: NSNotification.Name.playtoolsCursorWillHide,
object: nil, userInfo: [:])
AKInterface.shared!.hideCursor()

// Fix when people hold fake mouse while pressing option
// and it becomes random zoom in zoom out
ActionDispatcher.invalidateNonButtonActions()

if screen.fullscreen {
screen.switchDock(false)
}
Expand Down
64 changes: 44 additions & 20 deletions PlayTools/Controls/PTFakeTouch/PTFakeMetaTouch.m
Original file line number Diff line number Diff line change
Expand Up @@ -11,37 +11,54 @@
#import "UIApplication+Private.h"
#import "UIEvent+Private.h"
#import "CoreFoundation/CFRunLoop.h"
#import <stdatomic.h>
#include <dlfcn.h>
#include <string.h>

static NSMutableArray *livingTouchAry;
uint64_t reusageMask = 0;
atomic_ullong reusageMask = ATOMIC_VAR_INIT(0);
static CFRunLoopSourceRef source;

static UITouch *toStationarify = NULL;
NSLock *lock;

void eventSendCallback(void* info) {
UIEvent *event = [[UIApplication sharedApplication] _touchesEvent];
[event _clearTouches];
// Step1: copy touches and record began touches and mark recyclable touches
NSMutableArray *begunTouchAry = [[NSMutableArray alloc] init];
[lock lock];
[livingTouchAry enumerateObjectsUsingBlock:^(UITouch *aTouch, NSUInteger idx, BOOL *stop) {
switch (aTouch.phase) {
case UITouchPhaseEnded:
case UITouchPhaseCancelled:
// set this bit to 0
reusageMask |= 1ull<<idx;
// set this bit to 1
atomic_fetch_or(&reusageMask, 1ull<<idx);
break;
case UITouchPhaseBegan:
toStationarify = aTouch;
[begunTouchAry addObject:aTouch];
break;
default:
break;
}
[event _addTouch:aTouch forDelayedDelivery:NO];
}];
[lock unlock];

// Step2: send event
[[UIApplication sharedApplication] sendEvent:event];

// Step 3: change "began" touches to "moved"
// Do not let a "began" appear twice on a point
for (UITouch *touch in begunTouchAry) {
// Double check "began", because phase may have changed
@synchronized (touch) {
// Check condition needs to be synchronized too,
// otherwise phase might also change after condition met
if ([touch phase] == UITouchPhaseBegan) {
[touch setPhaseAndUpdateTimestamp:UITouchPhaseMoved];
}
}
}
}

@implementation PTFakeMetaTouch
Expand All @@ -60,28 +77,32 @@ + (void)load {

+ (NSInteger)fakeTouchId: (NSInteger)pointId AtPoint: (CGPoint)point withTouchPhase: (UITouchPhase)phase inWindow: (UIWindow*)window onView:(UIView*)view {
UITouch* touch = NULL;
if(toStationarify != NULL) {
// in case this is changed during the operations
touch = toStationarify;
toStationarify = NULL;
if(touch.phase == UITouchPhaseBegan) {
[touch setPhaseAndUpdateTimestamp:UITouchPhaseMoved];
}
}
// respect the semantics of touch phase, allocate new touch on touch began.
if(phase == UITouchPhaseBegan) {
touch = [[UITouch alloc] initAtPoint:point inWindow:window onView:view];
if(reusageMask == 0){
// Find and clear any 1 bit if possible
if(atomic_load(&reusageMask) == 0){
pointId = [livingTouchAry count];
[lock lock];
}else{
// reuse previous ID
pointId = 0;
while( !(reusageMask & (1ull<<pointId)) ){
// It is guanranteed other thread only "set" but not "clear" bit
// So this is safe even if mask changes around here
while( !(atomic_load(&reusageMask) & (1ull<<pointId)) ){
pointId++;
}
reusageMask &= ~(1ull<<pointId);
// issue: this could fail if not atomic
// How:
// 1. Other thread read
// 2. This thread read and write
// 3. Other thread write
[lock lock];
atomic_fetch_and(&reusageMask, ~(1ull<<pointId));
// These must be locked together, because otherwise
// After we occupy this id, other thread may release it again,
// before we actually replace the UITouch
}
[lock lock];
[livingTouchAry setObject:touch atIndexedSubscript:pointId];
[lock unlock];
} else {
Expand All @@ -90,11 +111,14 @@ + (NSInteger)fakeTouchId: (NSInteger)pointId AtPoint: (CGPoint)point withTouchPh
// previous touch began event not yet captured by runloop. Ignore this move
return pointId;
}
[touch setLocationInWindow:point];
[touch setPhaseAndUpdateTimestamp:phase];
@synchronized (touch) {
[touch setLocationInWindow:point];
[touch setPhaseAndUpdateTimestamp:phase];
}
}
CFRunLoopSourceSignal(source);
if(phase == UITouchPhaseEnded || phase == UITouchPhaseCancelled) {
// Check on actual phase of touch
if([touch phase] == UITouchPhaseEnded || [touch phase] == UITouchPhaseCancelled) {
pointId = -1;
}
return pointId;
Expand Down

0 comments on commit 4f3713b

Please sign in to comment.