diff --git a/src/Http.hx b/src/Http.hx index 075ca150..0c820386 100644 --- a/src/Http.hx +++ b/src/Http.hx @@ -11,6 +11,7 @@ typedef HttpRequest = { var fulfilled:Bool; var post:Bool; var postData:String; + var ?file:haxe.io.Bytes; }; class Http { @@ -23,7 +24,7 @@ class Http { public static function init() { #if sys - threadPool = new sys.thread.FixedThreadPool(2); + threadPool = new sys.thread.FixedThreadPool(4); threadPool.run(() -> threadLoop()); threadPool.run(() -> threadLoop()); threadPool.run(() -> threadLoop()); @@ -50,15 +51,21 @@ class Http { responses.add(() -> req.callback(b)); req.fulfilled = true; }; - hl.Gc.enable(false); - hl.Gc.blocking(true); // Wtf is this shit + + // 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); + if (req.file == null) { + http.setHeader('Content-Type', "application/json"); // support json data only (for now) + http.setPostData(req.postData); + } } + if (req.post && req.file != null) { + http.fileTransfer("hxfile", "hxfilename", new haxe.io.BytesInput(req.file), req.file.length); + } + hl.Gc.enable(false); http.request(req.post); - hl.Gc.blocking(false); + // hl.Gc.blocking(false); hl.Gc.enable(true); } } @@ -114,6 +121,36 @@ class Http { #end } + // Returns HTTPRequest on sys, Int on js + public static function uploadFile(url:String, data:haxe.io.Bytes, callback:haxe.io.Bytes->Void, errCallback:String->Void) { + var req = { + url: url, + callback: callback, + errCallback: errCallback, + cancelled: false, + fulfilled: false, + post: true, + postData: null, + file: data, + }; + #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/octet-stream", + }, + body: data.getData() + }).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/Leaderboards.hx b/src/Leaderboards.hx new file mode 100644 index 00000000..7acad469 --- /dev/null +++ b/src/Leaderboards.hx @@ -0,0 +1,80 @@ +package src; + +import net.Net; +import haxe.Json; +import src.Http; +import src.Console; +import src.Settings; + +typedef LBScore = { + name:String, + has_rec:Int, + score:Float, + platform:Int, + rewind:Int, +} + +enum abstract LeaderboardsKind(Int) { + var All; + var Rewind; + var NoRewind; +} + +class Leaderboards { + static var host = "http://127.0.0.1:7000"; + static var game = "Ultra"; + + public static function submitScore(mission:String, score:Float, rewindUsed:Bool, needsReplayCb:(Bool, Int) -> Void) { + if (!StringTools.startsWith(mission, "data/")) + mission = "data/" + mission; + Http.post('${host}/api/submit', Json.stringify({ + mission: mission, + score: score, + game: game, + name: Settings.highscoreName, + uid: Settings.userId, + rewind: rewindUsed ? 1 : 0, + platform: Net.getPlatform() + }), (b) -> { + var s = b.toString(); + var jd = Json.parse(s); + var status = jd.status; + Console.log("Score submitted"); + needsReplayCb(status == "new_record", status == "new_record" ? jd.rowid : 0); + }, (e) -> { + Console.log("Score submission failed: " + e); + }); + } + + public static function getScores(mission:String, kind:LeaderboardsKind, cb:Array->Void) { + if (!StringTools.startsWith(mission, "data/")) + mission = "data/" + mission; + return Http.get('${host}/api/scores?mission=${StringTools.urlEncode(mission)}&game=${game}&view=${kind}&count=10', (b) -> { + var s = b.toString(); + var scores:Array = Json.parse(s).scores; + cb(scores); + }, (e) -> { + Console.log("Failed to get scores: " + e); + cb([]); + }); + } + + public static function submitReplay(ref:Int, replay:haxe.io.Bytes) { + return Http.uploadFile('${host}/api/record?ref=${ref}', replay, (b) -> { + Console.log("Replay submitted"); + }, (e) -> { + Console.log("Replay submission failed: " + e); + }); + } + + public static function watchTopReplay(mission:String, kind:LeaderboardsKind, cb:haxe.io.Bytes->Void) { + if (!StringTools.startsWith(mission, "data/")) + mission = "data/" + mission; + return Http.get('${host}/api/replay?mission=${StringTools.urlEncode(mission)}&game=${game}&view=${kind}', (b) -> { + cb(b); + }, (e) -> { + Console.log("Failed to get replay: " + e); + cb(null); + }); + } +} diff --git a/src/MarbleGame.hx b/src/MarbleGame.hx index 40f173b7..839ea819 100644 --- a/src/MarbleGame.hx +++ b/src/MarbleGame.hx @@ -267,7 +267,7 @@ class MarbleGame { exitGameDlg = new ExitGameDlg((sender) -> { canvas.popDialog(exitGameDlg); var w = getWorld(); - if (w.isRecording) { + if (MarbleGame.instance.toRecord) { MarbleGame.canvas.pushDialog(new ReplayNameDlg(() -> { quitMission(); })); @@ -385,7 +385,7 @@ class MarbleGame { world.dispose(); } Analytics.trackLevelPlay(mission.title, mission.path); - world = new MarbleWorld(scene, scene2d, mission, toRecord, multiplayer); + world = new MarbleWorld(scene, scene2d, mission, !multiplayer, multiplayer); world.init(); } diff --git a/src/MarbleWorld.hx b/src/MarbleWorld.hx index ee25870b..8fd73818 100644 --- a/src/MarbleWorld.hx +++ b/src/MarbleWorld.hx @@ -204,6 +204,7 @@ class MarbleWorld extends Scheduler { // Rewind public var rewindManager:RewindManager; public var rewinding:Bool = false; + public var rewindUsed:Bool = false; public var inputRecorder:InputRecorder; public var isReplayingMovement:Bool = false; @@ -602,6 +603,7 @@ class MarbleWorld extends Scheduler { this.replay.rewind(); this.rewindManager.clear(); + this.rewindUsed = false; this.timeState.currentAttemptTime = 0; this.timeState.gameplayClock = this.gameMode.getStartTime(); @@ -1551,6 +1553,7 @@ class MarbleWorld extends Scheduler { var actualDt = timeState.currentAttemptTime - rframe.timeState.currentAttemptTime - dt * rewindManager.timeScale; dt = actualDt; rewindManager.applyFrame(rframe); + rewindUsed = true; } } diff --git a/src/Replay.hx b/src/Replay.hx index 5b9852b7..fe944666 100644 --- a/src/Replay.hx +++ b/src/Replay.hx @@ -500,6 +500,9 @@ class Replay { var compressed = stream.finalize(); #end + if (this.name == null) + this.name = this.mission; + var finalB = new BytesBuffer(); finalB.addByte(version); finalB.addByte(this.name.length); diff --git a/src/Settings.hx b/src/Settings.hx index 41fb9008..56031f6f 100644 --- a/src/Settings.hx +++ b/src/Settings.hx @@ -19,6 +19,7 @@ import haxe.Json; import src.Util; import src.Console; import src.Renderer; +import net.Uuid; typedef Score = { var name:String; @@ -211,6 +212,7 @@ class Settings { public static var achievementProgression:Int; public static var highscoreName = "Player"; + public static var userId = ""; public static var uiScale = 1.0; @@ -269,6 +271,7 @@ class Settings { gamepad: gamepadSettings, stats: playStatistics, highscoreName: highscoreName, + userId: userId, marbleIndex: optionsSettings.marbleIndex, marbleSkin: optionsSettings.marbleSkin, marbleModel: optionsSettings.marbleModel, @@ -475,6 +478,13 @@ class Settings { achievementProgression = 0; #end highscoreName = json.highscoreName; + if (highscoreName == null) { + highscoreName = ""; + } + userId = json.userId; + if (userId == null) { + userId = Uuid.v4(); + } } else { Console.warn("Settings file does not exist"); save(); diff --git a/src/collision/CollisionEntity.hx b/src/collision/CollisionEntity.hx index 41349ea8..ce0e77fb 100644 --- a/src/collision/CollisionEntity.hx +++ b/src/collision/CollisionEntity.hx @@ -246,6 +246,10 @@ class CollisionEntity implements IOctreeObject implements IBVHObject { if (position.sub(closest).dot(surfacenormal) > 0) { normal.normalize(); + trace(v0); + trace(v); + trace(v2); + // We find the normal that is closest to the surface normal, sort of fixes weird edge cases of when colliding with // var testDot = normal.dot(surfacenormal); // if (testDot > bestDot) { diff --git a/src/gui/EndGameGui.hx b/src/gui/EndGameGui.hx index 84d8e767..57db6ea5 100644 --- a/src/gui/EndGameGui.hx +++ b/src/gui/EndGameGui.hx @@ -15,6 +15,7 @@ import h3d.Vector; import src.ResourceLoader; import src.TimeState; import src.Util; +import src.Leaderboards; class EndGameGui extends GuiImage { var mission:Mission; @@ -134,6 +135,7 @@ class EndGameGui extends GuiImage { // Settings.saveScore(mission.path, myScore, scoreType); var scoreData:Array = Settings.getScores(mission.path); + while (scoreData.length < 1) { if (scoreType == Score) scoreData.push({name: "Nardo Polo", time: 0}); @@ -167,7 +169,11 @@ class EndGameGui extends GuiImage { retryButton.horizSizing = Right; retryButton.gamepadAccelerator = ["B"]; retryButton.accelerators = [hxd.Key.ESCAPE, hxd.Key.BACKSPACE]; - retryButton.pressedAction = (e) -> restartFunc(retryButton); + retryButton.pressedAction = (e) -> { + if (MarbleGame.canvas.children.length == 1) + restartFunc(retryButton); + } + bottomBar.addChild(retryButton); } @@ -183,8 +189,34 @@ class EndGameGui extends GuiImage { nextButton.horizSizing = Right; nextButton.gamepadAccelerator = ["A"]; nextButton.accelerators = [hxd.Key.ENTER]; - nextButton.pressedAction = (e) -> continueFunc(nextButton); + nextButton.pressedAction = (e) -> { + if (MarbleGame.canvas.children.length == 1) + continueFunc(nextButton); + } bottomBar.addChild(nextButton); + + if (bestScore.time == score) { + var submitScore = () -> { + var lbScoreValue = score; + if (scoreType == Score) + lbScoreValue = 1000 - score; + Leaderboards.submitScore(mission.path, lbScoreValue, MarbleGame.instance.world.rewindUsed, (needsReplay, ref) -> { + if (needsReplay) { + Leaderboards.submitReplay(ref, MarbleGame.instance.world.replay.write()); + } + }); + } + + if (Settings.highscoreName == "" || Settings.highscoreName == "Player Name") { + haxe.Timer.delay(() -> { + MarbleGame.canvas.pushDialog(new EnterNamePopupDlg(() -> { + submitScore(); + })); + }, 100); + } else { + submitScore(); + } + } } override function onResize(width:Int, height:Int) { diff --git a/src/gui/EnterNamePopupDlg.hx b/src/gui/EnterNamePopupDlg.hx new file mode 100644 index 00000000..368d4873 --- /dev/null +++ b/src/gui/EnterNamePopupDlg.hx @@ -0,0 +1,75 @@ +package gui; + +import h2d.Tile; +import hxd.BitmapData; +import h2d.filter.DropShadow; +import src.Settings; +import hxd.res.BitmapFont; +import h3d.Vector; +import src.ResourceLoader; +import src.MarbleGame; + +class EnterNamePopupDlg extends GuiImage { + public function new(callback:Void->Void) { + var res = ResourceLoader.getImage("data/ui/xbox/roundedBG.png").resource.toTile(); + super(res); + this.horizSizing = Width; + this.vertSizing = Height; + this.position = new Vector(); + this.extent = new Vector(640, 480); + + var arial14fontdata = ResourceLoader.getFileEntry("data/font/Arial Bold.fnt"); + var arial14b = new BitmapFont(arial14fontdata.entry); + @:privateAccess arial14b.loader = ResourceLoader.loader; + var arial14 = arial14b.toSdfFont(cast 21 * Settings.uiScale, h2d.Font.SDFChannel.MultiChannel); + + var yesNoFrame = new GuiImage(ResourceLoader.getResource("data/ui/xbox/popupGUI.png", ResourceLoader.getImage, this.imageResources).toTile()); + yesNoFrame.horizSizing = Center; + yesNoFrame.vertSizing = Center; + yesNoFrame.position = new Vector(70, 30); + yesNoFrame.extent = new Vector(512, 400); + this.addChild(yesNoFrame); + + var text = "Enter your name"; + + var yesNoText = new GuiMLText(arial14, null); + yesNoText.position = new Vector(103, 85); + yesNoText.extent = new Vector(313, 186); + yesNoText.text.text = text; + yesNoText.text.textColor = 0xEBEBEB; + yesNoFrame.addChild(yesNoText); + + var textFrame = new GuiControl(); + textFrame.position = new Vector(33, 107); + textFrame.extent = new Vector(232, 40); + textFrame.horizSizing = Center; + yesNoFrame.addChild(textFrame); + + var textInput = new GuiTextInput(arial14); + textInput.position = new Vector(6, 5); + textInput.extent = new Vector(216, 40); + textInput.horizSizing = Width; + textInput.vertSizing = Height; + textInput.text.textColor = 0xEBEBEB; + textInput.setCaretColor(0xEBEBEB); + textInput.text.selectionColor.setColor(0x8DFF8D); + textInput.text.selectionTile = h2d.Tile.fromColor(0x88BCEE, 0, hxd.Math.ceil(textInput.text.font.lineHeight)); + textFrame.addChild(textInput); + + textInput.text.text = Settings.highscoreName == "" ? "Player Name" : Settings.highscoreName; + + var okButton = new GuiXboxButton("Ok", 120); + okButton.position = new Vector(211, 248); + okButton.extent = new Vector(120, 94); + okButton.vertSizing = Top; + okButton.accelerators = [hxd.Key.ENTER]; + okButton.gamepadAccelerator = ["A"]; + okButton.pressedAction = (sender) -> { + Settings.highscoreName = textInput.text.text.substr(0, 15); // Max 15 pls + Settings.save(); + MarbleGame.canvas.popDialog(this); + callback(); + } + yesNoFrame.addChild(okButton); + } +} diff --git a/src/gui/GuiMLText.hx b/src/gui/GuiMLText.hx index 66f774cb..daf26334 100644 --- a/src/gui/GuiMLText.hx +++ b/src/gui/GuiMLText.hx @@ -7,7 +7,6 @@ import h2d.Bitmap; import h3d.Engine; import h3d.Vector; import gui.GuiText.Justification; -import h2d.HtmlText; import h2d.Scene; import hxd.res.BitmapFont; import h2d.Text; diff --git a/src/gui/HtmlText.hx b/src/gui/HtmlText.hx new file mode 100644 index 00000000..4ad0763d --- /dev/null +++ b/src/gui/HtmlText.hx @@ -0,0 +1,904 @@ +package gui; + +import h2d.Bitmap; +import h2d.TileGroup; +import h2d.Font; +import h2d.Tile; +import h2d.RenderContext; +import h2d.Interactive; +import h2d.Object; +import h2d.Text; + +/** + The `HtmlText` line height calculation rules. +**/ +enum LineHeightMode { + /** + Accurate line height calculations. Each line will adjust it's height according to it's contents. + **/ + Accurate; + + /** + Only text adjusts line heights, and `` tags do not affect it (partial legacy behavior). + **/ + TextOnly; + + /** + Legacy line height mode. When used, line heights remain constant based on `Text.font` variable. + **/ + Constant; +} + +/** + `HtmlText` img tag vertical alignment rules. +**/ +enum ImageVerticalAlign { + /** + Align images along the top of the text line. + **/ + Top; + + /** + Align images to sit on the base line of the text. + **/ + Bottom; + + /** + Align images to the middle between the top of the text line its base line. + **/ + Middle; +} + +/** + A simple HTML text renderer. + + See the [Text](https://github.com/HeapsIO/heaps/wiki/Text) section of the manual for more details and a list of the supported HTML tags. +**/ +class HtmlText extends Text { + /** + A default method HtmlText uses to load images for `` tag. See `HtmlText.loadImage` for details. + **/ + public static dynamic function defaultLoadImage(url:String):h2d.Tile { + return null; + } + + /** + A default method HtmlText uses to load fonts for `` tags with `face` attribute. See `HtmlText.loadFont` for details. + **/ + public static dynamic function defaultLoadFont(name:String):h2d.Font { + return null; + } + + /** + A default method HtmlText uses to format assigned text. See `HtmlText.formatText` for details. + **/ + public static dynamic function defaultFormatText(text:String):String { + return text; + } + + /** + When enabled, condenses extra spaces (carriage-return, line-feed, tabulation and space character) to one space. + If not set, uncondensed whitespace is left as is, as well as line-breaks. + **/ + public var condenseWhite(default, set):Bool = true; + + /** + The spacing after `` tags in pixels. + **/ + public var imageSpacing(default, set):Float = 1; + + /** + Line height calculation mode controls how much space lines take up vertically. + Changing mode to `Constant` restores the legacy behavior of HtmlText. + **/ + public var lineHeightMode(default, set):LineHeightMode = Accurate; + + /** + Vertical alignment of the images in `` tag relative to the text. + **/ + public var imageVerticalAlign(default, set):ImageVerticalAlign = Bottom; + + var elements:Array = []; + var xPos:Float; + var yPos:Float; + var xMax:Float; + var xMin:Float; + var textXml:Xml; + var sizePos:Int; + var dropMatrix:h3d.shader.ColorMatrix; + var prevChar:Int; + var newLine:Bool; + var aHrefs:Array; + var aInteractive:Interactive; + + override function draw(ctx:RenderContext) { + if (dropShadow != null) { + var oldX = absX, oldY = absY; + absX += dropShadow.dx * matA + dropShadow.dy * matC; + absY += dropShadow.dx * matB + dropShadow.dy * matD; + if (dropMatrix == null) { + dropMatrix = new h3d.shader.ColorMatrix(); + addShader(dropMatrix); + } + dropMatrix.enabled = true; + var m = dropMatrix.matrix; + m.zero(); + m._41 = ((dropShadow.color >> 16) & 0xFF) / 255; + m._42 = ((dropShadow.color >> 8) & 0xFF) / 255; + m._43 = (dropShadow.color & 0xFF) / 255; + m._44 = dropShadow.alpha; + for (e in elements) { + if (e is TileGroup) + @:privateAccess (cast(e, TileGroup)).drawWith(ctx, this); + } + @:privateAccess glyphs.drawWith(ctx, this); + dropMatrix.enabled = false; + absX = oldX; + absY = oldY; + } else { + removeShader(dropMatrix); + dropMatrix = null; + } + @:privateAccess glyphs.drawWith(ctx, this); + } + + override function getShader(stype:Class):T { + if (shaders != null) + for (s in shaders) { + var c = Std.downcast(s, h3d.shader.ColorMatrix); + if (c != null && !c.enabled) + continue; + var s = hxd.impl.Api.downcast(s, stype); + if (s != null) + return s; + } + return null; + } + + /** + Method that should return an `h2d.Tile` instance for `` tags. By default calls `HtmlText.defaultLoadImage` method. + + HtmlText does not cache tile instances. + Due to internal structure, method should be deterministic and always return same Tile on consequent calls with same `url` input. + @param url A value contained in `src` attribute. + **/ + public dynamic function loadImage(url:String):Tile { + return defaultLoadImage(url); + } + + /** + Method that should return an `h2d.Font` instance for `` tags with `face` attribute. By default calls `HtmlText.defaultLoadFont` method. + + HtmlText does not cache font instances and it's recommended to perform said caching from outside. + Due to internal structure, method should be deterministic and always return same Font instance on consequent calls with same `name` input. + @param name A value contained in `face` attribute. + @returns Method should return loaded font instance or `null`. If `null` is returned - currently active font is used. + **/ + public dynamic function loadFont(name:String):Font { + var f = defaultLoadFont(name); + if (f == null) + return this.font; + else + return f; + } + + /** + Called on a tag click + **/ + public dynamic function onHyperlink(url:String):Void {} + + /** + Called when text is assigned, allowing to process arbitrary text to a valid XHTML. + **/ + public dynamic function formatText(text:String):String { + return defaultFormatText(text); + } + + override function set_text(t:String) { + super.set_text(formatText(t)); + return t; + } + + function parseText(text:String) { + return try Xml.parse(text) catch (e:Dynamic) throw "Could not parse " + text + " (" + e + ")"; + } + + inline function makeLineInfo(width:Float, height:Float, baseLine:Float):LineInfo { + return {width: width, height: height, baseLine: baseLine}; + } + + override function validateText() { + textXml = parseText(text); + validateNodes(textXml); + } + + function validateNodes(xml:Xml) { + switch (xml.nodeType) { + case Element: + var nodeName = xml.nodeName.toLowerCase(); + switch (nodeName) { + case "img": + loadImage(xml.get("src")); + case "font": + if (xml.exists("face")) { + loadFont(xml.get("face")); + } + case "b", "bold": + loadFont("bold"); + case "i", "italic": + loadFont("italic"); + } + for (child in xml) + validateNodes(child); + case Document: + for (child in xml) + validateNodes(child); + default: + } + } + + override function initGlyphs(text:String, rebuild = true) { + if (rebuild) { + glyphs.clear(); + for (e in elements) + e.remove(); + elements = []; + } + glyphs.setDefaultColor(textColor); + + var doc:Xml; + if (textXml == null) { + doc = parseText(text); + } else { + doc = textXml; + } + + yPos = 0; + xMax = 0; + xMin = Math.POSITIVE_INFINITY; + sizePos = 0; + calcYMin = 0; + + var metrics:Array = [makeLineInfo(0, font.lineHeight, font.baseLine)]; + prevChar = -1; + newLine = true; + var splitNode:SplitNode = { + node: null, + pos: 0, + font: font, + prevChar: -1, + width: 0, + height: 0, + baseLine: 0 + }; + for (e in doc) + buildSizes(e, font, metrics, splitNode); + + var max = 0.; + for (info in metrics) { + if (info.width > max) + max = info.width; + } + calcWidth = max; + + prevChar = -1; + newLine = true; + nextLine(textAlign, metrics[0].width); + for (e in doc) + addNode(e, font, textAlign, rebuild, metrics); + + if (xPos > xMax) + xMax = xPos; + + textXml = null; + + var y = yPos; + calcXMin = xMin; + calcWidth = xMax - xMin; + calcHeight = y + metrics[sizePos].height; + calcSizeHeight = y + metrics[sizePos].baseLine; // (font.baseLine > 0 ? font.baseLine : font.lineHeight); + calcDone = true; + if (rebuild) + needsRebuild = false; + } + + function buildSizes(e:Xml, font:Font, metrics:Array, splitNode:SplitNode) { + function wordSplit() { + var fnt = splitNode.font; + var str = splitNode.node.nodeValue; + var info = metrics[metrics.length - 1]; + var w = info.width; + var cc = str.charCodeAt(splitNode.pos); + // Restore line metrics to ones before split. + // Potential bug: `Text [Image] texttext` - third line will use metrics as if image is present in the line. + info.width = splitNode.width; + info.height = splitNode.height; + info.baseLine = splitNode.baseLine; + var char = fnt.getChar(cc); + if (lineBreak && fnt.charset.isSpace(cc)) { + // Space characters are converted to \n + w -= (splitNode.width + letterSpacing + char.width + char.getKerningOffset(splitNode.prevChar)); + splitNode.node.nodeValue = str.substr(0, splitNode.pos) + "\n" + str.substr(splitNode.pos + 1); + } else { + w -= (splitNode.width + letterSpacing + char.getKerningOffset(splitNode.prevChar)); + splitNode.node.nodeValue = str.substr(0, splitNode.pos + 1) + "\n" + str.substr(splitNode.pos + 1); + } + splitNode.node = null; + return w; + } + inline function lineFont() { + return lineHeightMode == Constant ? this.font : font; + } + if (e.nodeType == Xml.Element) { + inline function makeLineBreak() { + var fontInfo = lineFont(); + metrics.push(makeLineInfo(0, fontInfo.lineHeight, fontInfo.baseLine)); + splitNode.node = null; + newLine = true; + prevChar = -1; + } + + var nodeName = e.nodeName.toLowerCase(); + switch (nodeName) { + case "p": + if (!newLine) { + makeLineBreak(); + } + case "br": + makeLineBreak(); + case "img": + // TODO: Support width/height attributes + // Support max-width/max-height attributes (downscale) + // Support min-width/min-height attributes (upscale) + var i:Tile = loadImage(e.get("src")); + if (i == null) + i = Tile.fromColor(0xFF00FF, 8, 8); + + var size = metrics[metrics.length - 1].width + i.width + imageSpacing; + if (realMaxWidth >= 0 && size > realMaxWidth && metrics[metrics.length - 1].width > 0) { + if (splitNode.node != null) { + size = wordSplit() + i.width + imageSpacing; + var info = metrics[metrics.length - 1]; + // Bug: height/baseLine may be innacurate in case of sizeA sizeBsizeA where sizeB is larger. + switch (lineHeightMode) { + case Accurate: + var grow = i.height - i.dy - info.baseLine; + var h = info.height; + var bl = info.baseLine; + if (grow > 0) { + h += grow; + bl += grow; + } + metrics.push(makeLineInfo(size, Math.max(h, bl + i.dy), bl)); + default: + metrics.push(makeLineInfo(size, info.height, info.baseLine)); + } + } + } else { + var info = metrics[metrics.length - 1]; + info.width = size; + if (lineHeightMode == Accurate) { + var grow = i.height - i.dy - info.baseLine; + if (grow > 0) { + switch (imageVerticalAlign) { + case Top: + info.height += grow; + case Bottom: + info.baseLine += grow; + info.height += grow; + case Middle: + info.height += grow; + info.baseLine += Std.int(grow / 2); + } + } + grow = info.baseLine + i.dy; + if (info.height < grow) + info.height = grow; + } + } + newLine = false; + prevChar = -1; + case "font": + for (a in e.attributes()) { + var v = e.get(a); + switch (a.toLowerCase()) { + case "face": font = loadFont(v); + default: + } + } + case "b", "bold": + font = loadFont("bold"); + case "i", "italic": + font = loadFont("italic"); + default: + } + for (child in e) + buildSizes(child, font, metrics, splitNode); + switch (nodeName) { + case "p": + if (!newLine) { + makeLineBreak(); + } + default: + } + } else if (e.nodeValue.length != 0) { + newLine = false; + var text = htmlToText(e.nodeValue); + var fontInfo = lineFont(); + var info:LineInfo = metrics.pop(); + var leftMargin = info.width; + var maxWidth = realMaxWidth < 0 ? Math.POSITIVE_INFINITY : realMaxWidth; + var textSplit = [], restPos = 0; + var x = leftMargin; + var breakChars = 0; + for (i in 0...text.length) { + var cc = text.charCodeAt(i); + var g = font.getChar(cc); + var newline = cc == '\n'.code; + var esize = g.width + g.getKerningOffset(prevChar); + var nc = text.charCodeAt(i + 1); + if (font.charset.isBreakChar(cc) && (nc == null || !font.charset.isComplementChar(nc))) { + // Case: Very first word in text makes the line too long hence we want to start it off on a new line. + if (x > maxWidth && textSplit.length == 0 && splitNode.node != null) { + metrics.push(makeLineInfo(x, info.height, info.baseLine)); + x = wordSplit(); + } + + var size = x + esize + letterSpacing; + var k = i + 1, max = text.length; + var prevChar = cc; + while (size <= maxWidth && k < max) { + var cc = text.charCodeAt(k++); + if (lineBreak && (font.charset.isSpace(cc) || cc == '\n'.code)) + break; + var e = font.getChar(cc); + size += e.width + letterSpacing + e.getKerningOffset(prevChar); + prevChar = cc; + var nc = text.charCodeAt(k); + if (font.charset.isBreakChar(cc) && (nc == null || !font.charset.isComplementChar(nc))) + break; + } + // Avoid empty line when last char causes line-break while being CJK + if (lineBreak && size > maxWidth && i != max - 1) { + // Next word will reach maxWidth + newline = true; + if (font.charset.isSpace(cc)) { + textSplit.push(text.substr(restPos, i - restPos)); + g = null; + } else { + textSplit.push(text.substr(restPos, i + 1 - restPos)); + breakChars++; + } + splitNode.node = null; + restPos = i + 1; + } else { + splitNode.node = e; + splitNode.pos = i + breakChars; + splitNode.prevChar = this.prevChar; + splitNode.width = x; + splitNode.height = info.height; + splitNode.baseLine = info.baseLine; + splitNode.font = font; + } + } + if (g != null && cc != '\n'.code) + x += esize + letterSpacing; + if (newline) { + metrics.push(makeLineInfo(x, info.height, info.baseLine)); + info.height = fontInfo.lineHeight; + info.baseLine = fontInfo.baseLine; + x = 0; + prevChar = -1; + newLine = true; + } else { + prevChar = cc; + newLine = false; + } + } + + if (restPos < text.length) { + if (x > maxWidth) { + if (splitNode.node != null && splitNode.node != e) { + metrics.push(makeLineInfo(x, info.height, info.baseLine)); + x = wordSplit(); + } + } + textSplit.push(text.substr(restPos)); + metrics.push(makeLineInfo(x, info.height, info.baseLine)); + } + + if (newLine || metrics.length == 0) { + metrics.push(makeLineInfo(0, fontInfo.lineHeight, fontInfo.baseLine)); + textSplit.push(""); + } + // Save node value + e.nodeValue = textSplit.join("\n"); + } + } + + static var REG_SPACES = ~/[\r\n\t ]+/g; + + function htmlToText(t:String) { + if (condenseWhite) + t = REG_SPACES.replace(t, " "); + return t; + } + + inline function nextLine(align:Align, size:Float) { + switch (align) { + case Left: + xPos = 0; + if (xMin > 0) + xMin = 0; + case Right, Center, MultilineCenter, MultilineRight: + var max = if (align == MultilineCenter || align == MultilineRight) hxd.Math.ceil(calcWidth) else + calcWidth < 0 ? 0 : hxd.Math.ceil(realMaxWidth); + var k = align == Center || align == MultilineCenter ? 0.5 : 1; + xPos = Math.ffloor((max - size) * k); + if (xPos < xMin) + xMin = xPos; + } + } + + override function splitText(text:String):String { + if (realMaxWidth < 0) + return text; + yPos = 0; + xMax = 0; + sizePos = 0; + calcYMin = 0; + + var doc = parseText(text); + + /* + This might require a global refactoring at some point. + We would need a way to somehow build an AST from the XML representation + with all sizes and word breaks so analysis is much more easy. + */ + + var splitNode:SplitNode = { + node: null, + font: font, + width: 0, + height: 0, + baseLine: 0, + pos: 0, + prevChar: -1 + }; + var metrics = [makeLineInfo(0, font.lineHeight, font.baseLine)]; + prevChar = -1; + newLine = true; + + for (e in doc) + buildSizes(e, font, metrics, splitNode); + xMax = 0; + function addBreaks(e:Xml) { + if (e.nodeType == Xml.Element) { + for (x in e) + addBreaks(x); + } else { + var text = e.nodeValue; + var startI = 0; + var index = Lambda.indexOf(e.parent, e); + for (i in 0...text.length) { + if (text.charCodeAt(i) == '\n'.code) { + var pre = text.substring(startI, i); + if (pre != "") + e.parent.insertChild(Xml.createPCData(pre), index++); + e.parent.insertChild(Xml.createElement("br"), index++); + startI = i + 1; + } + } + if (startI < text.length) { + e.nodeValue = text.substr(startI); + } else { + e.parent.removeChild(e); + } + } + } + for (d in doc) + addBreaks(d); + return doc.toString(); + } + + override function getTextProgress(text:String, progress:Float):String { + if (progress >= text.length) + return text; + var doc = parseText(text); + function progressRec(e:Xml) { + if (progress <= 0) { + e.parent.removeChild(e); + return; + } + if (e.nodeType == Xml.Element) { + for (x in [for (x in e) x]) + progressRec(x); + } else { + var text = htmlToText(e.nodeValue); + var len = text.length; + if (len > progress) { + text = text.substr(0, Std.int(progress)); + e.nodeValue = text; + } + progress -= len; + } + } + for (x in [for (x in doc) x]) + progressRec(x); + return doc.toString(); + } + + function addNode(e:Xml, font:Font, align:Align, rebuild:Bool, metrics:Array) { + inline function createInteractive() { + if (aHrefs == null || aHrefs.length == 0) + return; + aInteractive = new Interactive(0, metrics[sizePos].height, this); + var href = aHrefs[aHrefs.length - 1]; + aInteractive.onClick = function(event) { + onHyperlink(href); + } + aInteractive.x = xPos; + aInteractive.y = yPos; + elements.push(aInteractive); + } + + inline function finalizeInteractive() { + if (aInteractive != null) { + aInteractive.width = xPos - aInteractive.x; + aInteractive = null; + } + } + + inline function makeLineBreak() { + finalizeInteractive(); + if (xPos > xMax) + xMax = xPos; + yPos += metrics[sizePos].height + lineSpacing; + nextLine(align, metrics[++sizePos].width); + createInteractive(); + } + if (e.nodeType == Xml.Element) { + var prevColor = null, prevGlyphs = null; + var oldAlign = align; + var nodeName = e.nodeName.toLowerCase(); + inline function setFont(v:String) { + font = loadFont(v); + if (prevGlyphs == null) + prevGlyphs = glyphs; + var prev = glyphs; + glyphs = new TileGroup(font == null ? null : font.tile, this); + if (font != null) { + switch (font.type) { + case SignedDistanceField(channel, alphaCutoff, smoothing): + var shader = new h3d.shader.SignedDistanceField(); + shader.channel = channel; + shader.alphaCutoff = alphaCutoff; + shader.smoothing = smoothing; + shader.autoSmoothing = smoothing == -1; + glyphs.smooth = this.smooth; + glyphs.addShader(shader); + default: + } + } + @:privateAccess glyphs.curColor.load(prev.curColor); + elements.push(glyphs); + } + switch (nodeName) { + case "font": + for (a in e.attributes()) { + var v = e.get(a); + switch (a.toLowerCase()) { + case "color": + if (prevColor == null) + prevColor = @:privateAccess glyphs.curColor.clone(); + if (v.charCodeAt(0) == '#'.code && v.length == 4) + v = "#" + v.charAt(1) + v.charAt(1) + v.charAt(2) + v.charAt(2) + v.charAt(3) + v.charAt(3); + glyphs.setDefaultColor(Std.parseInt("0x" + v.substr(1))); + case "opacity": + if (prevColor == null) + prevColor = @:privateAccess glyphs.curColor.clone(); + @:privateAccess glyphs.curColor.a *= Std.parseFloat(v); + case "face": + setFont(v); + default: + } + } + case "p": + for (a in e.attributes()) { + switch (a.toLowerCase()) { + case "align": + var v = e.get(a); + if (v != null) switch (v.toLowerCase()) { + case "left": + align = Left; + case "center": + align = Center; + case "right": + align = Right; + case "multiline-center": + align = MultilineCenter; + case "multiline-right": + align = MultilineRight; + // ?justify + } + default: + } + } + if (!newLine) { + makeLineBreak(); + newLine = true; + prevChar = -1; + } else { + nextLine(align, metrics[sizePos].width); + } + case "offset": + for (a in e.attributes()) { + switch (a.toLowerCase()) { + case "value": + var v = e.get(a); + if (v != null) xPos = Std.parseFloat(v); + default: + } + } + // nextLine(align, metrics[sizePos].width); + + case "b", "bold": + setFont("bold"); + case "i", "italic": + setFont("italic"); + case "br": + makeLineBreak(); + newLine = true; + prevChar = -1; + case "img": + var i:Tile = loadImage(e.get("src")); + if (i == null) + i = Tile.fromColor(0xFF00FF, 8, 8); + var py = yPos; + switch (imageVerticalAlign) { + case Bottom: + py += metrics[sizePos].baseLine - i.height; + case Middle: + py += metrics[sizePos].baseLine - i.height / 2; + case Top: + } + if (py + i.dy < calcYMin) + calcYMin = py + i.dy; + if (rebuild) { + var b = new Bitmap(i, this); + b.x = xPos; + b.y = py; + elements.push(b); + } + newLine = false; + prevChar = -1; + xPos += i.width + imageSpacing; + case "a": + if (e.exists("href")) { + finalizeInteractive(); + if (aHrefs == null) + aHrefs = []; + aHrefs.push(e.get("href")); + createInteractive(); + } + default: + } + for (child in e) + addNode(child, font, align, rebuild, metrics); + align = oldAlign; + switch (nodeName) { + case "p": + if (newLine) { + nextLine(align, metrics[sizePos].width); + } else if (sizePos < metrics.length - 2 || metrics[sizePos + 1].width != 0) { + // Condition avoid extra empty line if

was the last tag. + makeLineBreak(); + newLine = true; + prevChar = -1; + } + case "a": + if (aHrefs.length > 0) { + finalizeInteractive(); + aHrefs.pop(); + createInteractive(); + } + default: + } + if (prevGlyphs != null) + glyphs = prevGlyphs; + if (prevColor != null) + @:privateAccess glyphs.curColor.load(prevColor); + } else if (e.nodeValue.length != 0) { + newLine = false; + var t = e.nodeValue; + var dy = metrics[sizePos].baseLine - font.baseLine; + for (i in 0...t.length) { + var cc = t.charCodeAt(i); + if (cc == "\n".code) { + makeLineBreak(); + dy = metrics[sizePos].baseLine - font.baseLine; + prevChar = -1; + continue; + } else { + var fc = font.getChar(cc); + if (fc != null) { + xPos += fc.getKerningOffset(prevChar); + if (rebuild) + glyphs.add(xPos, yPos + dy, fc.t); + if (yPos == 0 && fc.t.dy + dy < calcYMin) + calcYMin = fc.t.dy + dy; + xPos += fc.width + letterSpacing; + } + prevChar = cc; + } + } + } + } + + function set_imageSpacing(s) { + if (imageSpacing == s) + return s; + imageSpacing = s; + rebuild(); + return s; + } + + override function set_textColor(c) { + if (this.textColor == c) + return c; + this.textColor = c; + rebuild(); + return c; + } + + function set_condenseWhite(value:Bool) { + if (this.condenseWhite != value) { + this.condenseWhite = value; + rebuild(); + } + return value; + } + + function set_imageVerticalAlign(align) { + if (this.imageVerticalAlign != align) { + this.imageVerticalAlign = align; + rebuild(); + } + return align; + } + + function set_lineHeightMode(v) { + if (this.lineHeightMode != v) { + this.lineHeightMode = v; + rebuild(); + } + return v; + } + + override function getBoundsRec(relativeTo:Object, out:h2d.col.Bounds, forSize:Bool) { + if (forSize) + for (i in elements) + if (hxd.impl.Api.isOfType(i, h2d.Bitmap)) + i.visible = false; + super.getBoundsRec(relativeTo, out, forSize); + if (forSize) + for (i in elements) + i.visible = true; + } +} + +private typedef LineInfo = { + var width:Float; + var height:Float; + var baseLine:Float; +} + +private typedef SplitNode = { + var node:Xml; + var prevChar:Int; + var pos:Int; + var width:Float; + var height:Float; + var baseLine:Float; + var font:h2d.Font; +} diff --git a/src/gui/LeaderboardsGui.hx b/src/gui/LeaderboardsGui.hx new file mode 100644 index 00000000..61810261 --- /dev/null +++ b/src/gui/LeaderboardsGui.hx @@ -0,0 +1,291 @@ +package gui; + +import net.ClientConnection.NetPlatform; +import src.Util; +import src.Leaderboards.LeaderboardsKind; +import src.Mission; +import hxd.res.BitmapFont; +import src.ResourceLoader; +import src.Settings; +import h3d.Vector; +import src.MarbleGame; +import src.MissionList; +import src.Leaderboards; +import src.Replay; +import gui.HtmlText; + +class LeaderboardsGui extends GuiImage { + var innerCtrl:GuiControl; + + public function new(index:Int, levelSelectDifficulty:String, levelSelectGui:Bool = false,) { + var res = ResourceLoader.getImage("data/ui/xbox/BG_fadeOutSoftEdge.png").resource.toTile(); + super(res); + var domcasual32fontdata = ResourceLoader.getFileEntry("data/font/DomCasualD.fnt"); + var domcasual32b = new BitmapFont(domcasual32fontdata.entry); + @:privateAccess domcasual32b.loader = ResourceLoader.loader; + var domcasual32 = domcasual32b.toSdfFont(cast 42 * Settings.uiScale, MultiChannel); + + this.horizSizing = Width; + this.vertSizing = Height; + this.position = new Vector(); + this.extent = new Vector(640, 480); + + #if hl + var scene2d = hxd.Window.getInstance(); + #end + #if js + var scene2d = MarbleGame.instance.scene2d; + #end + + var offsetX = (scene2d.width - 1280) / 2; + var offsetY = (scene2d.height - 720) / 2; + + var subX = 640 - (scene2d.width - offsetX) * 640 / scene2d.width; + var subY = 480 - (scene2d.height - offsetY) * 480 / scene2d.height; + + innerCtrl = new GuiControl(); + innerCtrl.position = new Vector(offsetX, offsetY); + innerCtrl.extent = new Vector(640 - subX, 480 - subY); + innerCtrl.horizSizing = Width; + innerCtrl.vertSizing = Height; + this.addChild(innerCtrl); + + var coliseumfontdata = ResourceLoader.getFileEntry("data/font/ColiseumRR.fnt"); + var coliseumb = new BitmapFont(coliseumfontdata.entry); + @:privateAccess coliseumb.loader = ResourceLoader.loader; + var coliseum = coliseumb.toSdfFont(cast 44 * Settings.uiScale, MultiChannel); + + var rootTitle = new GuiText(coliseum); + rootTitle.position = new Vector(100, 30); + rootTitle.extent = new Vector(1120, 80); + rootTitle.text.textColor = 0xFFFFFF; + rootTitle.text.text = "LEADERBOARDS"; + rootTitle.text.alpha = 0.5; + innerCtrl.addChild(rootTitle); + + var levelTitle = new GuiText(coliseum); + levelTitle.position = new Vector(-100, 30); + levelTitle.extent = new Vector(1120, 80); + levelTitle.text.textColor = 0xFFFFFF; + levelTitle.text.text = "Level 1"; + levelTitle.text.textAlign = Right; + levelTitle.justify = Right; + levelTitle.text.alpha = 0.5; + innerCtrl.addChild(levelTitle); + + var scoreWnd = new GuiImage(ResourceLoader.getResource("data/ui/xbox/helpWindow.png", ResourceLoader.getImage, this.imageResources).toTile()); + scoreWnd.horizSizing = Right; + scoreWnd.vertSizing = Bottom; + scoreWnd.position = new Vector(260, 107); + scoreWnd.extent = new Vector(736, 325); + innerCtrl.addChild(scoreWnd); + + function imgLoader(path:String) { + var t = switch (path) { + case "ready": + ResourceLoader.getResource("data/ui/xbox/Ready.png", ResourceLoader.getImage, this.imageResources).toTile(); + case "notready": + ResourceLoader.getResource("data/ui/xbox/NotReady.png", ResourceLoader.getImage, this.imageResources).toTile(); + case "pc": + ResourceLoader.getResource("data/ui/xbox/platform_desktop_white.png", ResourceLoader.getImage, this.imageResources).toTile(); + case "mac": + ResourceLoader.getResource("data/ui/xbox/platform_mac_white.png", ResourceLoader.getImage, this.imageResources).toTile(); + case "web": + ResourceLoader.getResource("data/ui/xbox/platform_web_white.png", ResourceLoader.getImage, this.imageResources).toTile(); + case "android": + ResourceLoader.getResource("data/ui/xbox/platform_android_white.png", ResourceLoader.getImage, this.imageResources).toTile(); + case "unknown": + ResourceLoader.getResource("data/ui/xbox/platform_unknown_white.png", ResourceLoader.getImage, this.imageResources).toTile(); + case _: + return null; + }; + if (t != null) + t.scaleToSize(t.width * (Settings.uiScale), t.height * (Settings.uiScale)); + return t; + } + + var arial14fontdata = ResourceLoader.getFileEntry("data/font/Arial Bold.fnt"); + var arial14b = new BitmapFont(arial14fontdata.entry); + @:privateAccess arial14b.loader = ResourceLoader.loader; + var arial14 = arial14b.toSdfFont(cast 21 * Settings.uiScale, h2d.Font.SDFChannel.MultiChannel); + var arial12 = arial14b.toSdfFont(cast 16 * Settings.uiScale, h2d.Font.SDFChannel.MultiChannel); + function mlFontLoader(text:String) { + switch (text) { + case "arial14": + return arial14; + case "arial12": + return arial12; + } + return null; + } + + var headerText = 'RankNameScorePlatform'; + + var scores = [ + '1. Nardo Polo99:59:999', + '2. Nardo Polo99:59:999', + '3. Nardo Polo99:59:999', + '4. Nardo Polo99:59:999', + '5. Nardo Polo99:59:999', + '6. Nardo Polo99:59:999', + '7. Nardo Polo99:59:999', + '8. Nardo Polo99:59:999', + '9. Nardo Polo99:59:999', + '10. Nardo Polo99:59:999', + ]; + + var scoreCtrl = new GuiMLText(arial14, mlFontLoader); + scoreCtrl.position = new Vector(30, 20); + scoreCtrl.extent = new Vector(706, 305); + scoreCtrl.text.imageVerticalAlign = Top; + scoreCtrl.text.loadImage = imgLoader; + scoreCtrl.text.text = headerText + "




" + '

Loading...

'; + scoreWnd.addChild(scoreCtrl); + + var allMissions = MissionList.missionList.get('ultra') + .get('beginner') + .concat(MissionList.missionList.get('ultra').get('intermediate')) + .concat(MissionList.missionList.get('ultra').get('advanced')); + + var actualIndex = allMissions.indexOf(MissionList.missionList.get('ultra').get(levelSelectDifficulty)[index]); + + levelTitle.text.text = 'Level ${actualIndex + 1}'; + + var levelNames = allMissions.map(x -> x.title); + + var scoreCategories = ["Overall", "Rewind", "Non-Rewind"]; + var scoreView:LeaderboardsKind = All; + + var currentMission = allMissions[actualIndex]; + + var scoreTok = 0; + + function fetchScores() { + var ourToken = scoreTok++; + Leaderboards.getScores(currentMission.path, scoreView, (scoreList) -> { + if (ourToken + 1 != scoreTok) + return; + var scoreTexts = []; + var i = 1; + for (score in scoreList) { + var scoreText = '${i}. ${score.name}${Util.formatTime(score.score)}'; + scoreTexts.push(scoreText); + i++; + } + while (i <= 10) { + var scoreText = '${i}. Nardo Polo99:59.99'; + scoreTexts.push(scoreText); + i++; + } + scoreCtrl.text.text = headerText + "
" + scoreTexts.join('
'); + }); + scoreCtrl.text.text = headerText + "




" + '

Loading...

'; + } + + var levelSelectOpts = new GuiXboxOptionsList(2, "Overall", levelNames); + levelSelectOpts.position = new Vector(380, 485); + levelSelectOpts.extent = new Vector(815, 94); + levelSelectOpts.vertSizing = Bottom; + levelSelectOpts.horizSizing = Right; + levelSelectOpts.alwaysActive = true; + levelSelectOpts.onChangeFunc = (l) -> { + levelTitle.text.text = 'Level ${l + 1}'; + currentMission = allMissions[l]; + fetchScores(); + return true; + } + levelSelectOpts.setCurrentOption(actualIndex); + innerCtrl.addChild(levelSelectOpts); + + var bottomBar = new GuiControl(); + bottomBar.position = new Vector(0, 590); + bottomBar.extent = new Vector(640, 200); + bottomBar.horizSizing = Width; + bottomBar.vertSizing = Bottom; + innerCtrl.addChild(bottomBar); + + var backButton = new GuiXboxButton("Back", 160); + backButton.position = new Vector(400, 0); + backButton.vertSizing = Bottom; + backButton.horizSizing = Right; + backButton.gamepadAccelerator = ["B"]; + backButton.accelerators = [hxd.Key.ESCAPE, hxd.Key.BACKSPACE]; + if (levelSelectGui) + backButton.pressedAction = (e) -> MarbleGame.canvas.setContent(new LevelSelectGui(levelSelectDifficulty)); + else { + backButton.pressedAction = (e) -> MarbleGame.canvas.setContent(new MainMenuGui()); + } + bottomBar.addChild(backButton); + + var changeViewButton = new GuiXboxButton("Change View", 200); + changeViewButton.position = new Vector(560, 0); + changeViewButton.vertSizing = Bottom; + changeViewButton.horizSizing = Right; + changeViewButton.gamepadAccelerator = ["X"]; + changeViewButton.pressedAction = (e) -> { + scoreView = scoreView == All ? Rewind : (scoreView == Rewind ? NoRewind : All); + levelSelectOpts.labelText.text.text = scoreCategories[cast(scoreView, Int)]; + } + bottomBar.addChild(changeViewButton); + + var replayButton = new GuiXboxButton("Watch Replay", 220); + replayButton.position = new Vector(750, 0); + replayButton.vertSizing = Bottom; + replayButton.gamepadAccelerator = ["Y"]; + replayButton.horizSizing = Right; + replayButton.pressedAction = (e) -> { + Leaderboards.watchTopReplay(currentMission.path, scoreView, (b) -> { + if (b != null) { + var replayF = new Replay(""); + if (replayF.read(b)) { + var repmis = replayF.mission; + // Strip data/ from the mission name + if (StringTools.startsWith(repmis, "data/")) { + repmis = repmis.substr(5); + } + + var mi = MissionList.missions.get(repmis); + + // try with data/ added + if (mi == null) { + if (!StringTools.contains(repmis, "data/")) + repmis = "data/" + repmis; + mi = MissionList.missions.get(repmis); + } + + MarbleGame.instance.watchMissionReplay(mi, replayF); + } else { + MarbleGame.canvas.pushDialog(new MessageBoxOkDlg("Could not load replay for this level.")); + } + } else { + MarbleGame.canvas.pushDialog(new MessageBoxOkDlg("No top replay found for this level.")); + } + }); + } + bottomBar.addChild(replayButton); + + fetchScores(); + } + + override function onResize(width:Int, height:Int) { + var offsetX = (width - 1280) / 2; + var offsetY = (height - 720) / 2; + + var subX = 640 - (width - offsetX) * 640 / width; + var subY = 480 - (height - offsetY) * 480 / height; + innerCtrl.position = new Vector(offsetX, offsetY); + innerCtrl.extent = new Vector(640 - subX, 480 - subY); + + super.onResize(width, height); + } + + inline function platformToString(platform:NetPlatform) { + return switch (platform) { + case Unknown: return "unknown"; + case Android: return "android"; + case MacOS: return "mac"; + case PC: return "pc"; + case Web: return "web"; + } + } +} diff --git a/src/gui/LevelSelectGui.hx b/src/gui/LevelSelectGui.hx index 55a6cb3c..9360da67 100644 --- a/src/gui/LevelSelectGui.hx +++ b/src/gui/LevelSelectGui.hx @@ -160,11 +160,15 @@ class LevelSelectGui extends GuiImage { } bottomBar.addChild(recordButton); - // var lbButton = new GuiXboxButton("Leaderboard", 220); - // lbButton.position = new Vector(750, 0); - // lbButton.vertSizing = Bottom; - // lbButton.horizSizing = Right; - // bottomBar.addChild(lbButton); + if (currentDifficultyStatic != "multiplayer") { + var lbButton = new GuiXboxButton("Leaderboard", 220); + lbButton.position = new Vector(750, 0); + lbButton.vertSizing = Bottom; + lbButton.gamepadAccelerator = ["Y"]; + lbButton.horizSizing = Right; + lbButton.pressedAction = (e) -> MarbleGame.canvas.setContent(new LeaderboardsGui(currentSelectionStatic, currentDifficultyStatic, true)); + bottomBar.addChild(lbButton); + } var nextButton = new GuiXboxButton("Play", 160); nextButton.position = new Vector(960, 0); diff --git a/src/gui/MainMenuGui.hx b/src/gui/MainMenuGui.hx index 5753b626..94f413ae 100644 --- a/src/gui/MainMenuGui.hx +++ b/src/gui/MainMenuGui.hx @@ -65,7 +65,7 @@ class MainMenuGui extends GuiImage { btnList = new GuiXboxList(); btnList.position = new Vector(70 - offsetX, 95); btnList.horizSizing = Left; - btnList.extent = new Vector(502, 500); + btnList.extent = new Vector(502, 530); innerCtrl.addChild(btnList); btnList.addButton(0, "Single Player Game", (sender) -> { @@ -82,7 +82,9 @@ class MainMenuGui extends GuiImage { cast(this.parent, Canvas).setContent(new MultiplayerGui()); } }); - // btnList.addButton(2, "Leaderboards", (e) -> {}, 20); + btnList.addButton(2, "Leaderboards", (e) -> { + cast(this.parent, Canvas).setContent(new LeaderboardsGui(0, "beginner", false)); + }, 20); btnList.addButton(2, "Achievements", (e) -> { cast(this.parent, Canvas).setContent(new AchievementsGui()); }); diff --git a/src/net/Uuid.hx b/src/net/Uuid.hx new file mode 100644 index 00000000..0ab86b8b --- /dev/null +++ b/src/net/Uuid.hx @@ -0,0 +1,311 @@ +package net; + +/* + MIT License + https://github.com/flashultra/uuid + + Copyright (c) 2020 + + 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, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + 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 NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS 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. + + */ +import haxe.ds.Vector; +import haxe.Int64; +import haxe.io.Bytes; +import haxe.crypto.Md5; +import haxe.crypto.Sha1; + +class Uuid { + public inline static var DNS = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; + public inline static var URL = '6ba7b811-9dad-11d1-80b4-00c04fd430c8'; + public inline static var ISO_OID = '6ba7b812-9dad-11d1-80b4-00c04fd430c8'; + public inline static var X500_DN = '6ba7b814-9dad-11d1-80b4-00c04fd430c8'; + public inline static var NIL = '00000000-0000-0000-0000-000000000000'; + + public inline static var LOWERCASE_BASE26 = "abcdefghijklmnopqrstuvwxyz"; + public inline static var UPPERCASE_BASE26 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + public inline static var NO_LOOK_ALIKES_BASE51 = "2346789ABCDEFGHJKLMNPQRTUVWXYZabcdefghijkmnpqrtwxyz"; // without 1, l, I, 0, O, o, u, v, 5, S, s + public inline static var FLICKR_BASE58 = "123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"; // without similar characters 0/O, 1/I/l + public inline static var BASE_70 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-+!@#$^"; + public inline static var BASE_85 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-:+=^!/*?&<>()[]{}@%$#"; + public inline static var COOKIE_BASE90 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!#$%&'()*+-./:<=>?@[]^_`{|}~"; + public inline static var NANO_ID_ALPHABET = "_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + public inline static var NUMBERS_BIN = "01"; + public inline static var NUMBERS_OCT = "01234567"; + public inline static var NUMBERS_DEC = "0123456789"; + public inline static var NUMBERS_HEX = "0123456789abcdef"; + + static var lastMSecs:Float = 0; + static var lastNSecs = 0; + static var clockSequenceBuffer:Int = -1; + static var regexp:EReg = ~/^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i; + + static var rndSeed:Int64 = Int64.fromFloat(#if js js.lib.Date.now() #elseif sys Sys.time() * 1000 #else Date.now().getTime() #end); + static var state0 = splitmix64_seed(rndSeed); + static var state1 = splitmix64_seed(rndSeed + Std.random(10000) + 1); + private static var DVS:Int64 = Int64.make(0x00000001, 0x00000000); + + private static function splitmix64_seed(index:Int64):Int64 { + var result:Int64 = (index + Int64.make(0x9E3779B9, 0x7F4A7C15)); + result = (result ^ (result >> 30)) * Int64.make(0xBF58476D, 0x1CE4E5B9); + result = (result ^ (result >> 27)) * Int64.make(0x94D049BB, 0x133111EB); + return result ^ (result >> 31); + } + + public static function randomFromRange(min:Int, max:Int):Int { + var s1:Int64 = state0; + var s0:Int64 = state1; + state0 = s0; + s1 ^= s1 << 23; + state1 = s1 ^ s0 ^ (s1 >>> 18) ^ (s0 >>> 5); + var result:Int = ((state1 + s0) % (max - min + 1)).low; + result = (result < 0) ? -result : result; + return result + min; + } + + public static function randomByte():Int { + return randomFromRange(0, 255); + } + + public static function fromShort(shortUuid:String, separator:String = '-', fromAlphabet:String = FLICKR_BASE58):String { + var uuid = Uuid.convert(shortUuid, fromAlphabet, NUMBERS_HEX); + return hexToUuid(uuid, separator); + } + + public static function toShort(uuid:String, separator:String = '-', toAlphabet:String = FLICKR_BASE58):String { + uuid = StringTools.replace(uuid, separator, '').toLowerCase(); + return Uuid.convert(uuid, NUMBERS_HEX, toAlphabet); + } + + public static function fromNano(nanoUuid:String, separator:String = '-', fromAlphabet:String = NANO_ID_ALPHABET):String { + var uuid = Uuid.convert(nanoUuid, fromAlphabet, NUMBERS_HEX); + return hexToUuid(uuid, separator); + } + + public static function toNano(uuid:String, separator:String = '-', toAlphabet:String = NANO_ID_ALPHABET):String { + uuid = StringTools.replace(uuid, separator, '').toLowerCase(); + return Uuid.convert(uuid, NUMBERS_HEX, toAlphabet); + } + + public static function v1(node:Bytes = null, optClockSequence:Int = -1, msecs:Float = -1, optNsecs:Int = -1, ?randomFunc:Void->Int, + separator:String = "-", shortUuid:Bool = false, toAlphabet:String = FLICKR_BASE58):String { + if (randomFunc == null) + randomFunc = randomByte; + var buffer:Bytes = Bytes.alloc(16); + if (node == null) { + node = Bytes.alloc(6); + for (i in 0...6) + node.set(i, randomFunc()); + node.set(0, node.get(0) | 0x01); + } + if (clockSequenceBuffer == -1) { + clockSequenceBuffer = (randomFunc() << 8 | randomFunc()) & 0x3fff; + } + var clockSeq = optClockSequence; + if (optClockSequence == -1) { + clockSeq = clockSequenceBuffer; + } + if (msecs == -1) { + msecs = Math.fround(#if js js.lib.Date.now() #elseif sys Sys.time() * 1000 #else Date.now().getTime() #end); + } + var nsecs = optNsecs; + if (optNsecs == -1) { + nsecs = lastNSecs + 1; + } + var dt = (msecs - lastMSecs) + (nsecs - lastNSecs) / 10000; + if (dt < 0 && (optClockSequence == -1)) { + clockSeq = (clockSeq + 1) & 0x3fff; + } + if ((dt < 0 || msecs > lastMSecs) && optNsecs == -1) { + nsecs = 0; + } + if (nsecs >= 10000) { + throw "Can't create more than 10M uuids/sec"; + } + lastMSecs = msecs; + lastNSecs = nsecs; + clockSequenceBuffer = clockSeq; + + msecs += 12219292800000; + var imsecs:Int64 = Int64.fromFloat(msecs); + var tl:Int = (((imsecs & 0xfffffff) * 10000 + nsecs) % DVS).low; + buffer.set(0, tl >>> 24 & 0xff); + buffer.set(1, tl >>> 16 & 0xff); + buffer.set(2, tl >>> 8 & 0xff); + buffer.set(3, tl & 0xff); + + var tmh:Int = ((imsecs / DVS * 10000) & 0xfffffff).low; + buffer.set(4, tmh >>> 8 & 0xff); + buffer.set(5, tmh & 0xff); + + buffer.set(6, tmh >>> 24 & 0xf | 0x10); + buffer.set(7, tmh >>> 16 & 0xff); + + buffer.set(8, clockSeq >>> 8 | 0x80); + buffer.set(9, clockSeq & 0xff); + + for (i in 0...6) + buffer.set(i + 10, node.get(i)); + + var uuid = stringify(buffer, separator); + if (shortUuid) + uuid = Uuid.toShort(uuid, separator, toAlphabet); + + return uuid; + } + + public static function v3(name:String, namespace:String = "", separator:String = "-", shortUuid:Bool = false, toAlphabet:String = FLICKR_BASE58):String { + namespace = StringTools.replace(namespace, '-', ''); + var buffer = Md5.make(Bytes.ofHex(namespace + Bytes.ofString(name).toHex())); + buffer.set(6, (buffer.get(6) & 0x0f) | 0x30); + buffer.set(8, (buffer.get(8) & 0x3f) | 0x80); + var uuid = stringify(buffer, separator); + if (shortUuid) + uuid = Uuid.toShort(uuid, separator, toAlphabet); + return uuid; + } + + public static function v4(randBytes:Bytes = null, ?randomFunc:Void->Int, separator:String = "-", shortUuid:Bool = false, + toAlphabet:String = FLICKR_BASE58):String { + if (randomFunc == null) + randomFunc = randomByte; + var buffer:Bytes = randBytes; + if (buffer == null) { + buffer = Bytes.alloc(16); + for (i in 0...16) { + buffer.set(i, randomFunc()); + } + } else { + if (buffer.length < 16) + throw "Random bytes should be at least 16 bytes"; + } + buffer.set(6, (buffer.get(6) & 0x0f) | 0x40); + buffer.set(8, (buffer.get(8) & 0x3f) | 0x80); + var uuid = stringify(buffer, separator); + if (shortUuid) + uuid = Uuid.toShort(uuid, separator, toAlphabet); + return uuid; + } + + public static function v5(name:String, namespace:String = "", separator:String = "-", shortUuid:Bool = false, toAlphabet:String = FLICKR_BASE58):String { + namespace = StringTools.replace(namespace, '-', ''); + var buffer = Sha1.make(Bytes.ofHex(namespace + Bytes.ofString(name).toHex())); + buffer.set(6, (buffer.get(6) & 0x0f) | 0x50); + buffer.set(8, (buffer.get(8) & 0x3f) | 0x80); + var uuid = stringify(buffer, separator); + if (shortUuid) + uuid = Uuid.toShort(uuid, separator, toAlphabet); + return uuid; + } + + public static function stringify(data:Bytes, separator:String = "-"):String { + return hexToUuid(data.toHex(), separator); + } + + public static function parse(uuid:String, separator:String = "-"):Bytes { + return Bytes.ofHex(StringTools.replace(uuid, separator, '')); + } + + public static function validate(uuid:String, separator:String = "-"):Bool { + if (separator == "") { + uuid = uuid.substr(0, 8) + + "-" + + uuid.substr(8, 4) + + "-" + + uuid.substr(12, 4) + + "-" + + uuid.substr(16, 4) + + "-" + + uuid.substr(20, 12); + } else if (separator != "-") { + uuid = StringTools.replace(uuid, separator, '-'); + } + return regexp.match(uuid); + } + + public static function version(uuid:String, separator:String = "-"):Int { + uuid = StringTools.replace(uuid, separator, ''); + return Std.parseInt("0x" + uuid.substr(12, 1)); + } + + public static function hexToUuid(hex:String, separator:String):String { + return (hex.substr(0, 8) + separator + hex.substr(8, 4) + separator + hex.substr(12, 4) + separator + hex.substr(16, 4) + separator + + hex.substr(20, 12)); + } + + public static function convert(number:String, fromAlphabet:String, toAlphabet:String):String { + var fromBase:Int = fromAlphabet.length; + var toBase:Int = toAlphabet.length; + var len = number.length; + var buf:String = ""; + var numberMap:Vector = new Vector(len); + var divide:Int = 0, newlen:Int = 0; + for (i in 0...len) { + numberMap[i] = fromAlphabet.indexOf(number.charAt(i)); + } + do { + divide = 0; + newlen = 0; + for (i in 0...len) { + divide = divide * fromBase + numberMap[i]; + if (divide >= toBase) { + numberMap[newlen++] = Math.floor(divide / toBase); + divide = divide % toBase; + } else if (newlen > 0) { + numberMap[newlen++] = 0; + } + } + len = newlen; + buf = toAlphabet.charAt(divide) + buf; + } while (newlen != 0); + + return buf; + } + + public static function nanoId(len:Int = 21, alphabet:String = NANO_ID_ALPHABET, ?randomFunc:Void->Int):String { + if (randomFunc == null) + randomFunc = randomByte; + if (alphabet == null) + throw "Alphabet cannot be null"; + if (alphabet.length == 0 || alphabet.length >= 256) + throw "Alphabet must contain between 1 and 255 symbols"; + if (len <= 0) + throw "Length must be greater than zero"; + var mask:Int = (2 << Math.floor(Math.log(alphabet.length - 1) / Math.log(2))) - 1; + var step:Int = Math.ceil(1.6 * mask * len / alphabet.length); + var sb = new StringBuf(); + while (sb.length != len) { + for (i in 0...step) { + var rnd = randomFunc(); + var aIndex:Int = rnd & mask; + if (aIndex < alphabet.length) { + sb.add(alphabet.charAt(aIndex)); + if (sb.length == len) + break; + } + } + } + return sb.toString(); + } + + public static function short(toAlphabet:String = FLICKR_BASE58, ?randomFunc:Void->Int):String { + return Uuid.v4(randomFunc, true, toAlphabet); + } +}