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 index d3388ff7..0247aa98 100644 --- a/src/Http.hx +++ b/src/Http.hx @@ -1,6 +1,7 @@ package src; import src.Console; +import src.Util; typedef HttpRequest = { var url:String; @@ -8,6 +9,8 @@ typedef HttpRequest = { var errCallback:String->Void; var cancelled:Bool; var fulfilled:Bool; + var post:Bool; + var postData:String; }; class Http { @@ -41,7 +44,7 @@ class Http { var http = new sys.Http(req.url); http.onError = (e) -> { trace('HTTP Request Failed: ' + req.url); - responses.add(() -> req.errCallback(e)); + responses.add(() -> req.errCallback(e + ":" + http.responseBytes.toString())); req.fulfilled = true; }; http.onBytes = (b) -> { @@ -53,7 +56,12 @@ class Http { hl.Gc.blocking(true); // Wtf is this shit trace('HTTP Request: ' + req.url); #end - http.request(false); + 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); #if !MACOS_BUNDLE hl.Gc.blocking(false); #end @@ -68,7 +76,9 @@ class Http { callback: callback, errCallback: errCallback, cancelled: false, - fulfilled: false + fulfilled: false, + post: false, + postData: null, }; #if sys requests.add(req); @@ -80,6 +90,35 @@ class Http { #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); diff --git a/src/MarbleGame.hx b/src/MarbleGame.hx index 9da4b29b..e3a2fdca 100644 --- a/src/MarbleGame.hx +++ b/src/MarbleGame.hx @@ -27,6 +27,7 @@ import src.Settings; import src.Console; import src.Debug; import src.Gamepad; +import src.Analytics; import src.PreviewWorld; @:publicFields @@ -173,6 +174,9 @@ class MarbleGame { Window.getInstance().removeEventTarget(@:privateAccess Key.onEvent); #end + + Analytics.trackSingle("game-start"); + Analytics.trackPlatformInfo(); } public function update(dt:Float) { @@ -282,6 +286,9 @@ class MarbleGame { var missionType = world.mission.type; var isNotCustom = !world.mission.isClaMission && !world.mission.isCustom; 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); world.dispose(); world = null; paused = false; @@ -310,6 +317,7 @@ class MarbleGame { if (world != null) { world.dispose(); } + Analytics.trackLevelPlay(mission.title, mission.path); world = new MarbleWorld(scene, scene2d, mission, toRecord); world.init(); } @@ -317,6 +325,7 @@ class MarbleGame { public function watchMissionReplay(mission:Mission, replay:Replay) { canvas.clearContent(); destroyPreviewWorld(); + 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 1901127b..71a86cb2 100644 --- a/src/MarbleWorld.hx +++ b/src/MarbleWorld.hx @@ -93,6 +93,7 @@ import modes.GameMode; import modes.NullMode; import modes.GameMode.GameModeFactory; import src.Renderer; +import src.Analytics; class MarbleWorld extends Scheduler { public var collisionWorld:CollisionWorld; @@ -1418,6 +1419,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) { var myScore = { name: "Player", diff --git a/src/Util.hx b/src/Util.hx index a74c6789..ffa2e85a 100644 --- a/src/Util.hx +++ b/src/Util.hx @@ -445,4 +445,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 + } }