mirror of
https://github.com/RandomityGuy/MBHaxe.git
synced 2025-10-30 08:11:25 +00:00
change ws library, start "restart" netcode, fix some marble move bugs
This commit is contained in:
parent
5d5cb2b892
commit
604f858573
13 changed files with 164 additions and 76 deletions
|
|
@ -1,5 +1,5 @@
|
|||
-cp src
|
||||
-lib hxWebSockets
|
||||
-lib colyseus-websocket
|
||||
-lib datachannel
|
||||
-lib heaps
|
||||
-lib stb_ogg_sound
|
||||
|
|
@ -11,5 +11,6 @@
|
|||
-D highDPI
|
||||
-D flow_border
|
||||
-D analyzer-optimize
|
||||
--dce full
|
||||
--main Main
|
||||
-debug
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
-cp src
|
||||
-lib heaps
|
||||
-lib hlsdl
|
||||
-lib hxWebSockets
|
||||
-lib colyseus-websocket
|
||||
-lib datachannel
|
||||
-hl marblegame.hl
|
||||
-D windowSize=1280x720
|
||||
|
|
|
|||
|
|
@ -1796,17 +1796,23 @@ class Marble extends GameObject {
|
|||
|
||||
public function updateServer(timeState:TimeState, collisionWorld:CollisionWorld, pathedInteriors:Array<PathedInterior>) {
|
||||
var move:NetMove = null;
|
||||
if (this.controllable && this.mode != Finish && !MarbleGame.instance.paused && !this.level.isWatching && !this.level.isReplayingMovement) {
|
||||
if (this.controllable && this.mode != Finish) {
|
||||
if (Net.isClient) {
|
||||
var axis = getMarbleAxis()[1];
|
||||
move = Net.clientConnection.recordMove(cast this, axis, timeState, recvServerTick);
|
||||
} else if (Net.isHost) {
|
||||
var axis = getMarbleAxis()[1];
|
||||
var innerMove = recordMove();
|
||||
var qx = Std.int((innerMove.d.x * 16) + 16);
|
||||
var qy = Std.int((innerMove.d.y * 16) + 16);
|
||||
innerMove.d.x = (qx - 16) / 16.0;
|
||||
innerMove.d.y = (qy - 16) / 16.0;
|
||||
if (MarbleGame.instance.paused) {
|
||||
innerMove.d.x = 0;
|
||||
innerMove.d.y = 0;
|
||||
innerMove.blast = innerMove.jump = innerMove.powerup = false;
|
||||
} else {
|
||||
var qx = Std.int((innerMove.d.x * 16) + 16);
|
||||
var qy = Std.int((innerMove.d.y * 16) + 16);
|
||||
innerMove.d.x = (qx - 16) / 16.0;
|
||||
innerMove.d.y = (qy - 16) / 16.0;
|
||||
}
|
||||
move = new NetMove(innerMove, axis, timeState, recvServerTick, 65535);
|
||||
}
|
||||
}
|
||||
|
|
@ -1844,7 +1850,7 @@ class Marble extends GameObject {
|
|||
advancePhysics(timeState, move.move, collisionWorld, pathedInteriors);
|
||||
physicsAccumulator = 0;
|
||||
|
||||
if (move.move.jump && this.outOfBounds) {
|
||||
if (move.move.jump && this.outOfBounds && Net.isHost) {
|
||||
this.level.cancel(this.oobSchedule);
|
||||
this.level.restart(cast this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package src;
|
||||
|
||||
import net.MasterServerClient;
|
||||
import gui.MultiplayerLevelSelectGui;
|
||||
import net.NetCommands;
|
||||
import net.Net;
|
||||
|
|
@ -187,6 +188,7 @@ class MarbleGame {
|
|||
}
|
||||
|
||||
public function update(dt:Float) {
|
||||
MasterServerClient.process();
|
||||
if (world != null) {
|
||||
if (world._disposed) {
|
||||
world = null;
|
||||
|
|
|
|||
|
|
@ -204,7 +204,8 @@ class MarbleWorld extends Scheduler {
|
|||
// Multiplayer
|
||||
public var isMultiplayer:Bool;
|
||||
|
||||
public var startRealTime:Float = 0;
|
||||
public var serverStartTicks:Int;
|
||||
public var startTime:Float = 1e8;
|
||||
public var multiplayerStarted:Bool = false;
|
||||
|
||||
var tickAccumulator:Float = 0.0;
|
||||
|
|
@ -511,6 +512,16 @@ class MarbleWorld extends Scheduler {
|
|||
NetCommands.clientIsReady(Net.clientId);
|
||||
}
|
||||
|
||||
public function restartMultiplayerState() {
|
||||
if (this.isMultiplayer) {
|
||||
serverStartTicks = 0;
|
||||
lastMoves = new MarbleUpdateQueue();
|
||||
predictions = new MarblePredictionStore();
|
||||
powerupPredictions = new PowerupPredictionStore();
|
||||
gemPredictions = new GemPredictionStore();
|
||||
}
|
||||
}
|
||||
|
||||
public function restart(marble:Marble, full:Bool = false) {
|
||||
Console.log("LEVEL RESTART");
|
||||
if (!full && this.currentCheckpoint != null) {
|
||||
|
|
@ -535,6 +546,7 @@ class MarbleWorld extends Scheduler {
|
|||
|
||||
this.timeState.currentAttemptTime = 0;
|
||||
this.timeState.gameplayClock = this.gameMode.getStartTime();
|
||||
this.timeState.ticks = 0;
|
||||
this.bonusTime = 0;
|
||||
this.marble.outOfBounds = false;
|
||||
this.marble.blastAmount = 0;
|
||||
|
|
@ -678,7 +690,7 @@ class MarbleWorld extends Scheduler {
|
|||
}
|
||||
|
||||
public function allClientsReady() {
|
||||
NetCommands.setStartTime(3); // Start after 3 seconds
|
||||
NetCommands.setStartTicks(this.timeState.ticks);
|
||||
}
|
||||
|
||||
public function updateGameState() {
|
||||
|
|
@ -695,8 +707,9 @@ class MarbleWorld extends Scheduler {
|
|||
this.marble.setMode(Play);
|
||||
}
|
||||
} else {
|
||||
if (!this.multiplayerStarted) {
|
||||
if (this.startRealTime != 0 && this.timeState.timeSinceLoad > this.startRealTime) {
|
||||
if (!this.multiplayerStarted && this.finishTime == null) {
|
||||
if ((Net.isHost && (this.timeState.timeSinceLoad >= startTime)) // 3.5 == 109 ticks
|
||||
|| (Net.isClient && this.serverStartTicks != 0 && @:privateAccess this.marble.serverTicks >= this.serverStartTicks + 109)) {
|
||||
this.multiplayerStarted = true;
|
||||
this.marble.setMode(Play);
|
||||
for (client => marble in this.clientMarbles)
|
||||
|
|
@ -1636,10 +1649,15 @@ class MarbleWorld extends Scheduler {
|
|||
timeTravelSound.stop();
|
||||
timeTravelSound = null;
|
||||
}
|
||||
if (this.timeState.currentAttemptTime + skipStartBugPauseTime >= 3.5) {
|
||||
|
||||
if (!this.isMultiplayer) {
|
||||
if (this.timeState.currentAttemptTime + skipStartBugPauseTime >= 3.5) {
|
||||
this.timeState.gameplayClock += dt * timeMultiplier;
|
||||
} else if (this.timeState.currentAttemptTime + dt >= 3.5) {
|
||||
this.timeState.gameplayClock += ((this.timeState.currentAttemptTime + dt) - 3.5) * timeMultiplier;
|
||||
}
|
||||
} else if (this.multiplayerStarted) {
|
||||
this.timeState.gameplayClock += dt * timeMultiplier;
|
||||
} else if (this.timeState.currentAttemptTime + dt >= 3.5) {
|
||||
this.timeState.gameplayClock += ((this.timeState.currentAttemptTime + dt) - 3.5) * timeMultiplier;
|
||||
}
|
||||
if (this.timeState.gameplayClock < 0)
|
||||
this.gameMode.onTimeExpire();
|
||||
|
|
@ -1989,6 +2007,9 @@ class MarbleWorld extends Scheduler {
|
|||
if (Util.isTouchDevice()) {
|
||||
MarbleGame.instance.touchInput.setControlsEnabled(true);
|
||||
}
|
||||
if (this.isMultiplayer) {
|
||||
NetCommands.restartGame();
|
||||
}
|
||||
// @:privateAccess playGui.playGuiCtrl.render(scene2d);
|
||||
}
|
||||
if (MarbleGame.instance.toRecord) {
|
||||
|
|
@ -2165,7 +2186,7 @@ class MarbleWorld extends Scheduler {
|
|||
return null;
|
||||
});
|
||||
}
|
||||
if (Net.isHost) {
|
||||
if (!this.isMultiplayer || Net.isHost) {
|
||||
marble.oobSchedule = this.schedule(this.timeState.currentAttemptTime + 2.5, () -> {
|
||||
this.restart(marble);
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package src;
|
||||
|
||||
import hxd.Key;
|
||||
import shaders.RendererDefaultPass;
|
||||
import h3d.pass.PassList;
|
||||
import hxd.Window;
|
||||
|
|
@ -207,6 +208,18 @@ class Renderer extends h3d.scene.Renderer {
|
|||
copyPass.render();
|
||||
}
|
||||
|
||||
if (!cubemapPass) {
|
||||
#if sys
|
||||
if (Key.isDown(Key.CTRL) && Key.isPressed(Key.P)) {
|
||||
var pixels = backBuffer.capturePixels();
|
||||
var filename = StringTools.replace('Screenshot ${Date.now().toString()}.png', ":", ".");
|
||||
var pixdata = pixels.toPNG();
|
||||
hxd.File.createDirectory("data/screenshots");
|
||||
hxd.File.saveBytes("data/screenshots/" + filename, pixdata);
|
||||
}
|
||||
#end
|
||||
}
|
||||
|
||||
// h3d.pass.Copy.run(backBuffers[0], backBuffers[1]);
|
||||
// renderPass(defaultPass, get("refract"));
|
||||
// ctx.engine.popTarget();
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ class EndGameGui extends GuiImage {
|
|||
var mission:Mission;
|
||||
var innerCtrl:GuiControl;
|
||||
var endGameWnd:GuiImage;
|
||||
var retryFunc:GuiControl->Void;
|
||||
var nextFunc:GuiControl->Void;
|
||||
var continueFunc:GuiControl->Void;
|
||||
|
||||
var scoreSubmitted:Bool = false;
|
||||
|
||||
|
|
@ -31,6 +34,9 @@ class EndGameGui extends GuiImage {
|
|||
this.position = new Vector(0, 0);
|
||||
this.extent = new Vector(640, 480);
|
||||
this.mission = mission;
|
||||
this.retryFunc = restartFunc;
|
||||
this.nextFunc = nextLevelFunc;
|
||||
this.continueFunc = continueFunc;
|
||||
|
||||
function loadButtonImages(path:String) {
|
||||
var normal = ResourceLoader.getResource('${path}_n.png', ResourceLoader.getImage, this.imageResources).toTile();
|
||||
|
|
|
|||
|
|
@ -121,7 +121,18 @@ class MPServerListGui extends GuiImage {
|
|||
nextButton.gamepadAccelerator = ["X"];
|
||||
nextButton.pressedAction = (e) -> {
|
||||
MarbleGame.canvas.setContent(new MultiplayerLoadingGui("Connecting"));
|
||||
var failed = true;
|
||||
haxe.Timer.delay(() -> {
|
||||
if (failed) {
|
||||
var loadGui:MultiplayerLoadingGui = cast MarbleGame.canvas.content;
|
||||
if (loadGui != null) {
|
||||
loadGui.setErrorStatus("Failed to connect to server");
|
||||
Net.disconnect();
|
||||
}
|
||||
}
|
||||
}, 15000);
|
||||
Net.joinServer(ourServerList[curSelection].name, () -> {
|
||||
failed = false;
|
||||
MarbleGame.canvas.setContent(new MultiplayerLevelSelectGui(false));
|
||||
Net.remoteServerInfo = ourServerList[curSelection];
|
||||
});
|
||||
|
|
|
|||
|
|
@ -726,6 +726,30 @@ class PlayGui {
|
|||
playerListScoresCtrl.setTexts(plScores);
|
||||
}
|
||||
|
||||
public function doMPEndGameMessage() {
|
||||
playerList.sort((a, b) -> a.score > b.score ? -1 : (a.score < b.score ? 1 : 0));
|
||||
var p1 = playerList[0];
|
||||
var p2 = playerList.length > 1 ? playerList[1] : null;
|
||||
if (p2 == null) {
|
||||
var onePt = p1.score == 1;
|
||||
if (onePt)
|
||||
MarbleGame.instance.world.displayAlert('${p1.name} won with 1 point!');
|
||||
else
|
||||
MarbleGame.instance.world.displayAlert('${p1.name} won with ${p1.score} points!');
|
||||
} else {
|
||||
var tie = p1.score == p2.score;
|
||||
if (tie) {
|
||||
MarbleGame.instance.world.displayAlert('Game tied!');
|
||||
} else {
|
||||
var onePt = p1.score == 1;
|
||||
if (onePt)
|
||||
MarbleGame.instance.world.displayAlert('${p1.name} won with 1 point!');
|
||||
else
|
||||
MarbleGame.instance.world.displayAlert('${p1.name} won with ${p1.score} points!');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function addPlayer(id:Int, name:String, us:Bool) {
|
||||
playerList.push({
|
||||
id: id,
|
||||
|
|
|
|||
|
|
@ -287,7 +287,7 @@ class HuntMode extends NullMode {
|
|||
if (!this.level.isMultiplayer || Net.isHost) {
|
||||
rng.setSeed(100);
|
||||
rng2.setSeed(100);
|
||||
if (Settings.optionsSettings.huntRandom) {
|
||||
if (Settings.optionsSettings.huntRandom || Net.isMP) {
|
||||
rng.setSeed(cast Math.random() * 10000);
|
||||
rng2.setSeed(cast Math.random() * 10000);
|
||||
}
|
||||
|
|
@ -592,7 +592,11 @@ class HuntMode extends NullMode {
|
|||
level.marble.camera.finish = true;
|
||||
level.finishYaw = level.marble.camera.CameraYaw;
|
||||
level.finishPitch = level.marble.camera.CameraPitch;
|
||||
level.displayAlert("Congratulations! You've finished!");
|
||||
if (level.isMultiplayer) {
|
||||
@:privateAccess level.playGui.doMPEndGameMessage();
|
||||
} else {
|
||||
level.displayAlert("Congratulations! You've finished!");
|
||||
}
|
||||
level.cancel(@:privateAccess level.oobSchedule);
|
||||
level.cancel(@:privateAccess level.marble.oobSchedule);
|
||||
if (!level.isWatching) {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@ import gui.MessageBoxOkDlg;
|
|||
import src.MarbleGame;
|
||||
import haxe.Json;
|
||||
import net.Net.ServerInfo;
|
||||
import hx.ws.WebSocket;
|
||||
import haxe.net.WebSocket;
|
||||
import src.Console;
|
||||
import hx.ws.Types.MessageType;
|
||||
import gui.MultiplayerLoadingGui;
|
||||
|
||||
typedef RemoteServerInfo = {
|
||||
|
|
@ -26,29 +25,23 @@ class MasterServerClient {
|
|||
var open = false;
|
||||
|
||||
public function new(onOpenFunc:() -> Void) {
|
||||
#if sys
|
||||
var senderThread = sys.thread.Thread.current();
|
||||
#end
|
||||
ws = new WebSocket(serverIp);
|
||||
ws = WebSocket.create(serverIp);
|
||||
ws.onopen = () -> {
|
||||
open = true;
|
||||
#if sys
|
||||
senderThread.events.run(onOpenFunc);
|
||||
#end
|
||||
#if js
|
||||
onOpenFunc();
|
||||
#end
|
||||
}
|
||||
ws.onmessage = (m) -> {
|
||||
switch (m) {
|
||||
case StrMessage(content):
|
||||
handleMessage(content);
|
||||
case _:
|
||||
return;
|
||||
}
|
||||
ws.onmessageString = (m) -> {
|
||||
handleMessage(m);
|
||||
}
|
||||
}
|
||||
|
||||
public static function process() {
|
||||
#if sys
|
||||
if (instance != null)
|
||||
instance.ws.process();
|
||||
#end
|
||||
}
|
||||
|
||||
public static function connectToMasterServer(onConnect:() -> Void) {
|
||||
if (instance == null)
|
||||
instance = new MasterServerClient(onConnect);
|
||||
|
|
@ -71,7 +64,7 @@ class MasterServerClient {
|
|||
}
|
||||
|
||||
public function sendServerInfo(serverInfo:ServerInfo) {
|
||||
ws.send(Json.stringify({
|
||||
ws.sendString(Json.stringify({
|
||||
type: "serverInfo",
|
||||
name: serverInfo.name,
|
||||
players: serverInfo.players,
|
||||
|
|
@ -84,7 +77,7 @@ class MasterServerClient {
|
|||
}
|
||||
|
||||
public function sendConnectToServer(serverName:String, sdp:String) {
|
||||
ws.send(Json.stringify({
|
||||
ws.sendString(Json.stringify({
|
||||
type: "connect",
|
||||
serverName: serverName,
|
||||
sdp: sdp
|
||||
|
|
@ -93,7 +86,7 @@ class MasterServerClient {
|
|||
|
||||
public function getServerList(serverListCb:Array<RemoteServerInfo>->Void) {
|
||||
this.serverListCb = serverListCb;
|
||||
ws.send(Json.stringify({
|
||||
ws.sendString(Json.stringify({
|
||||
type: "serverList"
|
||||
}));
|
||||
}
|
||||
|
|
@ -108,7 +101,7 @@ class MasterServerClient {
|
|||
}
|
||||
if (conts.type == "connect") {
|
||||
if (!Net.isHost) {
|
||||
ws.send(Json.stringify({
|
||||
ws.sendString(Json.stringify({
|
||||
type: "connectFailed",
|
||||
success: false,
|
||||
reason: "The server has shut down"
|
||||
|
|
@ -116,7 +109,7 @@ class MasterServerClient {
|
|||
return;
|
||||
}
|
||||
if (Net.serverInfo.players >= Net.serverInfo.maxPlayers) {
|
||||
ws.send(Json.stringify({
|
||||
ws.sendString(Json.stringify({
|
||||
type: "connectFailed",
|
||||
success: false,
|
||||
reason: "The server is full"
|
||||
|
|
@ -124,7 +117,7 @@ class MasterServerClient {
|
|||
return;
|
||||
}
|
||||
Net.addClientFromSdp(conts.sdp, (sdpReply) -> {
|
||||
ws.send(Json.stringify({
|
||||
ws.sendString(Json.stringify({
|
||||
success: true,
|
||||
type: "connectResponse",
|
||||
sdp: sdpReply,
|
||||
|
|
|
|||
|
|
@ -70,39 +70,47 @@ class MoveManager {
|
|||
}
|
||||
var move = new Move();
|
||||
move.d = new Vector();
|
||||
move.d.x = Gamepad.getAxis(Settings.gamepadSettings.moveYAxis);
|
||||
move.d.y = -Gamepad.getAxis(Settings.gamepadSettings.moveXAxis);
|
||||
if (Key.isDown(Settings.controlsSettings.forward)) {
|
||||
move.d.x -= 1;
|
||||
}
|
||||
if (Key.isDown(Settings.controlsSettings.backward)) {
|
||||
move.d.x += 1;
|
||||
}
|
||||
if (Key.isDown(Settings.controlsSettings.left)) {
|
||||
move.d.y += 1;
|
||||
}
|
||||
if (Key.isDown(Settings.controlsSettings.right)) {
|
||||
move.d.y -= 1;
|
||||
}
|
||||
if (Key.isDown(Settings.controlsSettings.jump)
|
||||
|| MarbleGame.instance.touchInput.jumpButton.pressed
|
||||
|| Gamepad.isDown(Settings.gamepadSettings.jump)) {
|
||||
move.jump = true;
|
||||
}
|
||||
if ((!Util.isTouchDevice() && Key.isDown(Settings.controlsSettings.powerup))
|
||||
|| (Util.isTouchDevice() && MarbleGame.instance.touchInput.powerupButton.pressed)
|
||||
|| Gamepad.isDown(Settings.gamepadSettings.powerup)) {
|
||||
move.powerup = true;
|
||||
}
|
||||
if (!MarbleGame.instance.paused) {
|
||||
move.d.x = Gamepad.getAxis(Settings.gamepadSettings.moveYAxis);
|
||||
move.d.y = -Gamepad.getAxis(Settings.gamepadSettings.moveXAxis);
|
||||
if (Key.isDown(Settings.controlsSettings.forward)) {
|
||||
move.d.x -= 1;
|
||||
}
|
||||
if (Key.isDown(Settings.controlsSettings.backward)) {
|
||||
move.d.x += 1;
|
||||
}
|
||||
if (Key.isDown(Settings.controlsSettings.left)) {
|
||||
move.d.y += 1;
|
||||
}
|
||||
if (Key.isDown(Settings.controlsSettings.right)) {
|
||||
move.d.y -= 1;
|
||||
}
|
||||
if (Key.isDown(Settings.controlsSettings.jump)
|
||||
|| MarbleGame.instance.touchInput.jumpButton.pressed
|
||||
|| Gamepad.isDown(Settings.gamepadSettings.jump)) {
|
||||
move.jump = true;
|
||||
}
|
||||
if ((!Util.isTouchDevice() && Key.isDown(Settings.controlsSettings.powerup))
|
||||
|| (Util.isTouchDevice() && MarbleGame.instance.touchInput.powerupButton.pressed)
|
||||
|| Gamepad.isDown(Settings.gamepadSettings.powerup)) {
|
||||
move.powerup = true;
|
||||
}
|
||||
|
||||
if (Key.isDown(Settings.controlsSettings.blast)
|
||||
|| (MarbleGame.instance.touchInput.blastbutton.pressed)
|
||||
|| Gamepad.isDown(Settings.gamepadSettings.blast))
|
||||
move.blast = true;
|
||||
if (Key.isDown(Settings.controlsSettings.blast)
|
||||
|| (MarbleGame.instance.touchInput.blastbutton.pressed)
|
||||
|| Gamepad.isDown(Settings.gamepadSettings.blast))
|
||||
move.blast = true;
|
||||
|
||||
if (MarbleGame.instance.touchInput.movementInput.pressed) {
|
||||
move.d.y = -MarbleGame.instance.touchInput.movementInput.value.x;
|
||||
move.d.x = MarbleGame.instance.touchInput.movementInput.value.y;
|
||||
if (MarbleGame.instance.touchInput.movementInput.pressed) {
|
||||
move.d.y = -MarbleGame.instance.touchInput.movementInput.value.x;
|
||||
move.d.x = MarbleGame.instance.touchInput.movementInput.value.y;
|
||||
}
|
||||
|
||||
// quantize moves for client
|
||||
var qx = Std.int((move.d.x * 16) + 16);
|
||||
var qy = Std.int((move.d.y * 16) + 16);
|
||||
move.d.x = (qx - 16) / 16.0;
|
||||
move.d.y = (qy - 16) / 16.0;
|
||||
}
|
||||
|
||||
var netMove = new NetMove(move, motionDir, timeState.clone(), serverTicks, nextMoveId++);
|
||||
|
|
|
|||
|
|
@ -17,11 +17,9 @@ import net.NetPacket.MarbleMovePacket;
|
|||
import haxe.Json;
|
||||
import datachannel.RTCPeerConnection;
|
||||
import datachannel.RTCDataChannel;
|
||||
import hx.ws.WebSocket;
|
||||
import src.Console;
|
||||
import net.NetCommands;
|
||||
import src.MarbleGame;
|
||||
import hx.ws.Types.MessageType;
|
||||
import src.Settings;
|
||||
|
||||
enum abstract NetPacketType(Int) from Int to Int {
|
||||
|
|
@ -371,7 +369,8 @@ class Net {
|
|||
var movePacket = new MarbleMovePacket();
|
||||
movePacket.deserialize(input);
|
||||
var cc = clientIdMap[movePacket.clientId];
|
||||
cc.queueMove(movePacket.move);
|
||||
if (cc.state == GAME)
|
||||
cc.queueMove(movePacket.move);
|
||||
|
||||
case PowerupPickup:
|
||||
var powerupPickupPacket = new PowerupPickupPacket();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue