diff --git a/src/Analytics.hx b/src/Analytics.hx new file mode 100644 index 00000000..4026b7f8 --- /dev/null +++ b/src/Analytics.hx @@ -0,0 +1,147 @@ +package src; + +import haxe.Json; +import src.Util; +import src.Settings; +import src.Http; +import src.Console; + +typedef PayloadData = { + type:String, + payload:{ + hostname:String, language:String, referrer:String, screen:String, title:String, url:String, website:String, name:String, ?data:Dynamic + } +}; + +// I'm sorry to add this +// Your data is private and anonymous and we don't track you at all, I promise! +// The analytics are stored in a self hosted Umami instance inside EU. +class Analytics { + static var umami = "https://analytics.randomityguy.me/api/send"; + + public static function trackSingle(eventName:String) { + var p = payload(eventName, null); + var json = Json.stringify(p); + Http.post(umami, json, (b) -> { + // Console.log("Analytics suceeded: " + b.toString()); + }, (e) -> { + // Console.log("Analytics failed: " + e); + }); + } + + public static function trackLevelPlay(levelName:String, levelFile:String) { + var p = payload("level-play", { + name: levelName, + file: levelFile + }); + var json = Json.stringify(p); + Http.post(umami, json, (b) -> { + // Console.log("Analytics suceeded: " + b.toString()); + }, (e) -> { + // Console.log("Analytics failed: " + e); + }); + } + + public static function trackLevelScore(levelName:String, levelFile:String, time:Int, oobs:Int, respawns:Int, rewind:Bool) { + var p = payload("level-score", { + name: levelName, + file: levelFile, + time: time, + oobs: oobs, + respawns: respawns, + rewind: rewind + }); + var json = Json.stringify(p); + Http.post(umami, json, (b) -> { + // Console.log("Analytics suceeded: " + b.toString()); + }, (e) -> { + // Console.log("Analytics failed: " + e); + }); + } + + public static function trackLevelQuit(levelName:String, levelFile:String, time:Int, oobs:Int, respawns:Int, rewind:Bool) { + var p = payload("level-quit", { + name: levelName, + file: levelFile, + time: time, + oobs: oobs, + respawns: respawns, + rewind: rewind + }); + var json = Json.stringify(p); + Http.post(umami, json, (b) -> { + // Console.log("Analytics suceeded: " + b.toString()); + }, (e) -> { + // Console.log("Analytics failed: " + e); + }); + } + + public static function trackPlatformInfo() { + var p = payload("device-telemetry", { + platform: Util.getPlatform(), + screen: screen(), + }); + var json = Json.stringify(p); + Http.post(umami, json, (b) -> { + // Console.log("Analytics suceeded: " + b.toString()); + }, (e) -> { + // Console.log("Analytics failed: " + e); + }); + } + + static function payload(eventName:String, eventData:Dynamic):PayloadData { + var p:PayloadData = { + type: "event", + payload: { + hostname: hostname(), + language: language(), + referrer: referrer(), + screen: screen(), + title: "MBHaxe Platinum", + url: "/", + website: "e6da43f0-fc6a-49cb-a4a9-4b7e7745e538", + name: eventName + } + }; + if (eventData == null) + return p; + p.payload.data = eventData; + return p; + } + + static function hostname() { + #if js + return js.Browser.window.location.hostname; + #end + #if hl + return "marbleblast.randomityguy.me"; + #end + } + + static function language() { + #if js + return js.Browser.window.navigator.language; + #end + #if hl + return "en-us"; + #end + } + + static function referrer() { + #if js + return js.Browser.window.document.referrer; + #end + #if hl + return ""; + #end + } + + static function screen() { + #if js + return '${js.Browser.window.screen.width}x${js.Browser.window.screen.height}'; + #end + #if hl + return '${Settings.optionsSettings.screenWidth}x${Settings.optionsSettings.screenHeight}'; + #end + } +} diff --git a/src/Http.hx b/src/Http.hx new file mode 100644 index 00000000..fc33df86 --- /dev/null +++ b/src/Http.hx @@ -0,0 +1,137 @@ +package src; + +import src.Console; +import src.Util; + +typedef HttpRequest = { + var url:String; + var callback:haxe.io.Bytes->Void; + var errCallback:String->Void; + var cancelled:Bool; + var fulfilled:Bool; + var post:Bool; + var postData:String; +}; + +class Http { + #if sys + static var threadPool:sys.thread.FixedThreadPool; + static var requests:sys.thread.Deque = new sys.thread.Deque(); + static var responses:sys.thread.Deque<() -> Void> = new sys.thread.Deque<() -> Void>(); + static var cancellationMutex:sys.thread.Mutex = new sys.thread.Mutex(); + #end + + public static function init() { + #if sys + threadPool = new sys.thread.FixedThreadPool(4); + threadPool.run(() -> threadLoop()); + threadPool.run(() -> threadLoop()); + threadPool.run(() -> threadLoop()); + threadPool.run(() -> threadLoop()); + #end + } + + #if sys + static function threadLoop() { + while (true) { + var req = requests.pop(true); + cancellationMutex.acquire(); + if (req.cancelled) { + cancellationMutex.release(); + continue; + } + cancellationMutex.release(); + var http = new sys.Http(req.url); + http.onError = (e) -> { + responses.add(() -> req.errCallback(e + ":" + http.responseBytes.toString())); + req.fulfilled = true; + }; + http.onBytes = (b) -> { + responses.add(() -> req.callback(b)); + req.fulfilled = true; + }; + hl.Gc.enable(false); + hl.Gc.blocking(true); // Wtf is this shit + if (req.post) { + http.setHeader('User-Agent', 'MBHaxe/1.0 ${Util.getPlatform()}'); + http.setHeader('Content-Type', "application/json"); // support json data only (for now) + http.setPostData(req.postData); + } + http.request(req.post); + hl.Gc.blocking(false); + hl.Gc.enable(true); + } + } + #end + + // Returns HTTPRequest on sys, Int on js + public static function get(url:String, callback:haxe.io.Bytes->Void, errCallback:String->Void) { + var req = { + url: url, + callback: callback, + errCallback: errCallback, + cancelled: false, + fulfilled: false, + post: false, + postData: null, + }; + #if sys + requests.add(req); + return req; + #else + return js.Browser.window.setTimeout(() -> { + js.Browser.window.fetch(url).then(r -> r.arrayBuffer().then(b -> callback(haxe.io.Bytes.ofData(b))), e -> errCallback(e.toString())); + }, 75); + #end + } + + // Returns HTTPRequest on sys, Int on js + public static function post(url:String, postData:String, callback:haxe.io.Bytes->Void, errCallback:String->Void) { + var req = { + url: url, + callback: callback, + errCallback: errCallback, + cancelled: false, + fulfilled: false, + post: true, + postData: postData + }; + #if sys + requests.add(req); + return req; + #else + return js.Browser.window.setTimeout(() -> { + js.Browser.window.fetch(url, + { + method: "POST", + headers: { + "User-Agent": js.Browser.window.navigator.userAgent, + "Content-Type": "application/json", + }, + body: postData + }).then(r -> r.arrayBuffer().then(b -> callback(haxe.io.Bytes.ofData(b))), e -> errCallback(e.toString())); + }, 75); + #end + } + + public static function loop() { + #if sys + var resp = responses.pop(false); + if (resp != null) { + resp(); + } + #end + } + + #if sys + public static function cancel(req:HttpRequest) { + cancellationMutex.acquire(); + req.cancelled = true; + cancellationMutex.release(); + } + #else + public static function cancel(req:Int) { + js.Browser.window.clearTimeout(req); + } + #end +} diff --git a/src/MarbleGame.hx b/src/MarbleGame.hx index fec67535..e3613487 100644 --- a/src/MarbleGame.hx +++ b/src/MarbleGame.hx @@ -17,6 +17,7 @@ import gui.Canvas; import src.Util; import src.ProfilerUI; import src.Gamepad; +import src.Analytics; @:publicFields class MarbleGame { @@ -155,6 +156,9 @@ class MarbleGame { Window.getInstance().removeEventTarget(@:privateAccess Key.onEvent); #end + + Analytics.trackSingle("game-start"); + Analytics.trackPlatformInfo(); } public function update(dt:Float) { @@ -220,6 +224,9 @@ class MarbleGame { public function quitMission() { world.setCursorLock(false); + var stats = Settings.levelStatistics[world.mission.path]; + Analytics.trackLevelQuit(world.mission.title, world.mission.path, Std.int(world.timeState.timeSinceLoad * 1000), stats.oobs, stats.respawns, + Settings.optionsSettings.rewindEnabled); paused = false; var pmg = new PlayMissionGui(); PlayMissionGui.currentSelectionStatic = world.mission.index; @@ -233,6 +240,10 @@ class MarbleGame { public function playMission(mission:Mission) { canvas.clearContent(); + if (world != null) { + world.dispose(); + } + Analytics.trackLevelPlay(mission.title, mission.path); world = new MarbleWorld(scene, scene2d, mission, toRecord); toRecord = false; world.init(); @@ -240,6 +251,7 @@ class MarbleGame { public function watchMissionReplay(mission:Mission, replay:Replay) { canvas.clearContent(); + Analytics.trackSingle("replay-watch"); world = new MarbleWorld(scene, scene2d, mission); world.replay = replay; world.isWatching = true; diff --git a/src/MarbleWorld.hx b/src/MarbleWorld.hx index e7df8320..48303fd5 100644 --- a/src/MarbleWorld.hx +++ b/src/MarbleWorld.hx @@ -79,6 +79,7 @@ import src.ProfilerUI; import src.ResourceLoaderWorker; import src.Gamepad; import src.ResourceLoader; +import src.Analytics; class MarbleWorld extends Scheduler { public var collisionWorld:CollisionWorld; @@ -1335,6 +1336,15 @@ class MarbleWorld extends Scheduler { this.finishYaw = this.marble.camera.CameraYaw; this.finishPitch = this.marble.camera.CameraPitch; displayAlert("Congratulations! You've finished!"); + if (!Settings.levelStatistics.exists(mission.path)) { + Settings.levelStatistics.set(mission.path, { + oobs: 0, + respawns: 0, + totalTime: 0, + }); + } + Analytics.trackLevelScore(mission.title, mission.path, Std.int(finishTime.gameplayClock * 1000), Settings.levelStatistics[mission.path].oobs, + Settings.levelStatistics[mission.path].respawns, Settings.optionsSettings.rewindEnabled); if (!this.isWatching) this.schedule(this.timeState.currentAttemptTime + 2, () -> cast showFinishScreen()); // Stop the ongoing sounds diff --git a/src/Util.hx b/src/Util.hx index 1fa3362f..3dbd0695 100644 --- a/src/Util.hx +++ b/src/Util.hx @@ -389,4 +389,17 @@ class Util { return totBytes.getBytes().toString(); } + + public static function getPlatform() { + #if js + return js.Browser.navigator.platform; + #end + #if hl + #if MACOS_BUNDLE + return "MacOS"; + #else + return "Windows"; + #end + #end + } }