diff --git a/.gitignore b/.gitignore index aa06509..c0c2529 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,4 @@ target /knockoutwhist/ /knockoutwhistweb/.g8/ /knockoutwhistweb/.bsp/ +/currentSnapshot.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..6cc01af --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ + + + + + + +## User Password Protection + +All the User Passwords are encrypted using Argon2. \ No newline at end of file diff --git a/bruno/KnockOutWhist/Game/Create Game.bru b/bruno/KnockOutWhist/Game/Create Game.bru new file mode 100644 index 0000000..2402878 --- /dev/null +++ b/bruno/KnockOutWhist/Game/Create Game.bru @@ -0,0 +1,16 @@ +meta { + name: Create Game + type: http + seq: 1 +} + +post { + url: {{host}}/createGame + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/KnockOutWhist/Game/Get Game.bru b/bruno/KnockOutWhist/Game/Get Game.bru new file mode 100644 index 0000000..6b6d6af --- /dev/null +++ b/bruno/KnockOutWhist/Game/Get Game.bru @@ -0,0 +1,20 @@ +meta { + name: Get Game + type: http + seq: 2 +} + +get { + url: {{host}}/game/:id + body: none + auth: inherit +} + +params:path { + id: uZDNZA +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/KnockOutWhist/Game/Start Game.bru b/bruno/KnockOutWhist/Game/Start Game.bru new file mode 100644 index 0000000..cc231bf --- /dev/null +++ b/bruno/KnockOutWhist/Game/Start Game.bru @@ -0,0 +1,20 @@ +meta { + name: Start Game + type: http + seq: 3 +} + +post { + url: {{host}}/game/:id/start + body: none + auth: inherit +} + +params:path { + id: uZDNZA +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/KnockOutWhist/Game/folder.bru b/bruno/KnockOutWhist/Game/folder.bru new file mode 100644 index 0000000..a279bdf --- /dev/null +++ b/bruno/KnockOutWhist/Game/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Game + seq: 3 +} + +auth { + mode: inherit +} diff --git a/bruno/KnockOutWhist/Login.bru b/bruno/KnockOutWhist/Login.bru new file mode 100644 index 0000000..2aecc28 --- /dev/null +++ b/bruno/KnockOutWhist/Login.bru @@ -0,0 +1,26 @@ +meta { + name: Login + type: http + seq: 2 +} + +post { + url: {{host}}/login + body: formUrlEncoded + auth: inherit +} + +body:form-urlencoded { + username: Janis + password: password123 +} + +body:multipart-form { + username: Janis + password: password123 +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/KnockOutWhist/bruno.json b/bruno/KnockOutWhist/bruno.json new file mode 100644 index 0000000..c687c5e --- /dev/null +++ b/bruno/KnockOutWhist/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "KnockOutWhist", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/bruno/KnockOutWhist/collection.bru b/bruno/KnockOutWhist/collection.bru new file mode 100644 index 0000000..e69de29 diff --git a/bruno/KnockOutWhist/environments/Local.bru b/bruno/KnockOutWhist/environments/Local.bru new file mode 100644 index 0000000..b22b9b6 --- /dev/null +++ b/bruno/KnockOutWhist/environments/Local.bru @@ -0,0 +1,3 @@ +vars { + host: http://localhost:9000 +} diff --git a/build.sbt b/build.sbt index 535154e..50d5526 100644 --- a/build.sbt +++ b/build.sbt @@ -37,7 +37,10 @@ lazy val knockoutwhistweb = project.in(file("knockoutwhistweb")) .dependsOn(knockoutwhist % "compile->compile;test->test") .settings( commonSettings, - libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test + libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test, + libraryDependencies += "de.mkammerer" % "argon2-jvm" % "2.12", + libraryDependencies += "com.auth0" % "java-jwt" % "4.3.0", + libraryDependencies += "com.github.ben-manes.caffeine" % "caffeine" % "3.2.2" ) lazy val root = (project in file(".")) diff --git a/knockoutwhist b/knockoutwhist index fbc0ea2..e0e45c4 160000 --- a/knockoutwhist +++ b/knockoutwhist @@ -1 +1 @@ -Subproject commit fbc0ea2277596e2a2d29125b5f9a84213336dc18 +Subproject commit e0e45c4b431fff6740e38a59906f5e217fcd801f diff --git a/knockoutwhistweb/app/assets/stylesheets/login.less b/knockoutwhistweb/app/assets/stylesheets/login.less new file mode 100644 index 0000000..2e3bb1e --- /dev/null +++ b/knockoutwhistweb/app/assets/stylesheets/login.less @@ -0,0 +1,35 @@ +.login-box { + position: fixed; /* changed from absolute to fixed so it centers relative to the viewport */ + align-items: center; + justify-content: center; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); /* center exactly */ + display: flex; + width: 100%; + max-width: 420px; /* keeps box from stretching too wide */ + padding: 1rem; + z-index: 2; /* above particles */ +} + +.login-card { + max-width: 400px; + width: 100%; + border: none; + border-radius: 1rem; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + position: relative; + z-index: 3; /* ensure card sits above the particles */ +} + +#particles-js { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; /* behind content */ + pointer-events: none; /* allow clicks through particles */ + background-repeat: no-repeat; + background-size: cover; +} \ No newline at end of file diff --git a/knockoutwhistweb/app/assets/stylesheets/main.less b/knockoutwhistweb/app/assets/stylesheets/main.less index 6c77638..8bc3b69 100644 --- a/knockoutwhistweb/app/assets/stylesheets/main.less +++ b/knockoutwhistweb/app/assets/stylesheets/main.less @@ -1,5 +1,6 @@ @import "light-mode.less"; @import "dark-mode.less"; +@import "login.less"; @background-image: var(--background-image); @color: var(--color); @@ -7,14 +8,16 @@ 0% { transform: translateX(-100vw); } 100% { transform: translateX(0); } } -body { +.game-field-background { background-image: @background-image; background-size: 100vw 100vh; + background-repeat: no-repeat; } -html, body { - height: 100vh; - margin: 0; +.game-field { + position: fixed; + inset: 0; + overflow: auto; } #sessions { display: flex; @@ -31,8 +34,9 @@ html, body { animation: slideIn 0.5s ease-out forwards; animation-fill-mode: backwards; animation-delay: 1s; - } -#sessions a, h1, p { +} + +#sessions a, #sessions h1, #sessions p { color: @color; font-size: 40px; font-family: Arial, serif; @@ -44,6 +48,11 @@ html, body { justify-content: flex-end; height: 100%; } +#ingame a, #ingame h1, #ingame p { + color: @color; + font-size: 40px; + font-family: Arial, serif; +} #playercards { display: flex; flex-direction: row; diff --git a/knockoutwhistweb/app/auth/Auth.scala b/knockoutwhistweb/app/auth/Auth.scala new file mode 100644 index 0000000..b956035 --- /dev/null +++ b/knockoutwhistweb/app/auth/Auth.scala @@ -0,0 +1,37 @@ +package auth + +import controllers.routes +import logic.user.SessionManager +import model.users.User +import play.api.mvc.* + +import javax.inject.Inject +import scala.concurrent.{ExecutionContext, Future} + +class AuthenticatedRequest[A](val user: User, request: Request[A]) extends WrappedRequest[A](request) + +class AuthAction @Inject()(val sessionManager: SessionManager, val parser: BodyParsers.Default)(implicit ec: ExecutionContext) + extends ActionBuilder[AuthenticatedRequest, AnyContent] { + + override def executionContext: ExecutionContext = ec + + private def getUserFromSession(request: RequestHeader): Option[User] = { + val session = request.cookies.get("sessionId") + if (session.isDefined) + return sessionManager.getUserBySession(session.get.value) + None + } + + override def invokeBlock[A]( + request: Request[A], + block: AuthenticatedRequest[A] => Future[Result] + ): Future[Result] = { + getUserFromSession(request) match { + case Some(user) => + block(new AuthenticatedRequest(user, request)) + case None => + Future.successful(Results.Redirect(routes.UserController.login())) + } + } +} + diff --git a/knockoutwhistweb/app/components/WebApplicationConfiguration.scala b/knockoutwhistweb/app/components/WebApplicationConfiguration.scala index 8226cf8..2f13029 100644 --- a/knockoutwhistweb/app/components/WebApplicationConfiguration.scala +++ b/knockoutwhistweb/app/components/WebApplicationConfiguration.scala @@ -1,13 +1,12 @@ package components -import controllers.WebUI import de.knockoutwhist.components.DefaultConfiguration import de.knockoutwhist.ui.UI import de.knockoutwhist.utils.events.EventListener class WebApplicationConfiguration extends DefaultConfiguration { - override def uis: Set[UI] = super.uis + WebUI - override def listener: Set[EventListener] = super.listener + WebUI + override def uis: Set[UI] = Set() + override def listener: Set[EventListener] = Set() } diff --git a/knockoutwhistweb/app/controllers/HomeController.scala b/knockoutwhistweb/app/controllers/HomeController.scala deleted file mode 100644 index 7de648f..0000000 --- a/knockoutwhistweb/app/controllers/HomeController.scala +++ /dev/null @@ -1,93 +0,0 @@ -package controllers - -import com.google.inject.{Guice, Injector} -import controllers.sessions.AdvancedSession -import de.knockoutwhist.KnockOutWhist -import de.knockoutwhist.components.Configuration -import de.knockoutwhist.control.GameState.{InGame, Lobby, SelectTrump, TieBreak} -import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic -import di.KnockOutWebConfigurationModule -import play.api.mvc.* -import play.api.* -import play.twirl.api.Html - -import java.util.UUID -import javax.inject.* - - -/** - * This controller creates an `Action` to handle HTTP requests to the - * application's home page. - */ -@Singleton -class HomeController @Inject()(val controllerComponents: ControllerComponents) extends BaseController { - - private var initial = false - private val injector: Injector = Guice.createInjector(KnockOutWebConfigurationModule()) - - /** - * Create an Action to render an HTML page. - * - * The configuration in the `routes` file means that this method - * will be called when the application receives a `GET` request with - * a path of `/`. - */ - def index(): Action[AnyContent] = { - if (!initial) { - initial = true - KnockOutWhist.entry(injector.getInstance(classOf[Configuration])) - } - Action { implicit request => - Redirect("/sessions") - } - } - def rules(): Action[AnyContent] = { - Action { implicit request => - Ok(views.html.rules.apply()) - } - } - def sessions(): Action[AnyContent] = { - Action { implicit request => - Ok(views.html.sessions.apply(PodGameManager.listSessions())) - } - } - - def ingame(id: String): Action[AnyContent] = { - val uuid: UUID = UUID.fromString(id) - if (PodGameManager.identify(uuid).isEmpty) { - return Action { implicit request => - NotFound(views.html.tui.apply(List(Html(s"

Session with id $id not found!

")))) - } - } else { - val session = PodGameManager.identify(uuid).get - val player = session.asInstanceOf[AdvancedSession].player - val logic = WebUI.logic.get.asInstanceOf[BaseGameLogic] - if (logic.getCurrentState == Lobby) { - - } else if (logic.getCurrentState == InGame) { - return Action { implicit request => - Ok(views.html.ingame.apply(player, logic)) - } - } else if (logic.getCurrentState == SelectTrump) { - return Action { implicit request => - Ok(views.html.selecttrump.apply(player, logic)) - } - } else if (logic.getCurrentState == TieBreak) { - return Action { implicit request => - Ok(views.html.tie.apply(player, logic)) - } - } - } - Action { implicit request => - InternalServerError("Oops") - } - //if (logic.getCurrentState == Lobby) { - //Action { implicit request => - //Ok(views.html.tui.apply(player, logic)) - //} - //} else { - //Action { implicit request => - //Ok(views.html.tui.apply(player, logic)) - //} - } -} \ No newline at end of file diff --git a/knockoutwhistweb/app/controllers/IngameController.scala b/knockoutwhistweb/app/controllers/IngameController.scala new file mode 100644 index 0000000..ef588a8 --- /dev/null +++ b/knockoutwhistweb/app/controllers/IngameController.scala @@ -0,0 +1,244 @@ +package controllers + +import auth.{AuthAction, AuthenticatedRequest} +import de.knockoutwhist.control.GameState.{InGame, Lobby, SelectTrump, TieBreak} +import exceptions.{CantPlayCardException, GameFullException, NotEnoughPlayersException, NotHostException, NotInThisGameException} +import logic.PodManager +import play.api.* +import play.api.mvc.* + +import javax.inject.* +import scala.util.Try + + +/** + * This controller creates an `Action` to handle HTTP requests to the + * application's home page. + */ +@Singleton +class IngameController @Inject()( + val controllerComponents: ControllerComponents, + val authAction: AuthAction, + val podManager: PodManager + ) extends BaseController { + + def game(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val game = podManager.getGame(gameId) + game match { + case Some(g) => + g.logic.getCurrentState match { + case Lobby => Ok("Lobby: " + gameId) + case InGame => + Ok(views.html.ingame.ingame( + g.getPlayerByUser(request.user), + g.logic + )) + case SelectTrump => + Ok(views.html.ingame.selecttrump( + g.getPlayerByUser(request.user), + g.logic + )) + case TieBreak => + Ok(views.html.ingame.tie( + g.getPlayerByUser(request.user), + g.logic + )) + case _ => + InternalServerError(s"Invalid game state for in-game view. GameId: $gameId" + s" State: ${g.logic.getCurrentState}") + } + case None => + NotFound("Game not found") + } + //NotFound(s"Reached end of game method unexpectedly. GameId: $gameId") + } + def startGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val game = podManager.getGame(gameId) + val result = Try { + game match { + case Some(g) => + g.startGame(request.user) + case None => + NotFound("Game not found") + } + } + if (result.isSuccess) { + NoContent + } else { + val throwable = result.failed.get + throwable match { + case _: NotInThisGameException => + BadRequest(throwable.getMessage) + case _: NotHostException => + Forbidden(throwable.getMessage) + case _: NotEnoughPlayersException => + BadRequest(throwable.getMessage) + case _ => + InternalServerError(throwable.getMessage) + } + } + } + def joinGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val game = podManager.getGame(gameId) + val result = Try { + game match { + case Some(g) => + g.addUser(request.user) + case None => + NotFound("Game not found") + } + } + if (result.isSuccess) { + Redirect(routes.IngameController.game(gameId)) + } else { + val throwable = result.failed.get + throwable match { + case _: GameFullException => + BadRequest(throwable.getMessage) + case _: IllegalArgumentException => + BadRequest(throwable.getMessage) + case _: IllegalStateException => + BadRequest(throwable.getMessage) + case _ => + InternalServerError(throwable.getMessage) + } + } + } + def playCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => { + val game = podManager.getGame(gameId) + game match { + case Some(g) => + val cardIdOpt = request.body.asFormUrlEncoded.flatMap(_.get("cardId").flatMap(_.headOption)) + cardIdOpt match { + case Some(cardId) => + val result = Try { + g.playCard(g.getUserSession(request.user.id), cardId.toInt) + } + if (result.isSuccess) { + NoContent + } else { + val throwable = result.failed.get + throwable match { + case _: CantPlayCardException => + BadRequest(throwable.getMessage) + case _: NotInThisGameException => + BadRequest(throwable.getMessage) + case _: IllegalArgumentException => + BadRequest(throwable.getMessage) + case _: IllegalStateException => + BadRequest(throwable.getMessage) + case _ => + InternalServerError(throwable.getMessage) + } + } + case None => + BadRequest("cardId parameter is missing") + } + case None => + NotFound("Game not found") + } + } + } + def playDogCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => { + val game = podManager.getGame(gameId) + game match { + case Some(g) => { + val cardIdOpt = request.body.asFormUrlEncoded.flatMap(_.get("cardId").flatMap(_.headOption)) + val result = Try { + cardIdOpt match { + case Some(cardId) if cardId == "skip" => + g.playDogCard(g.getUserSession(request.user.id), -1) + case Some(cardId) => + g.playDogCard(g.getUserSession(request.user.id), cardId.toInt) + case None => + throw new IllegalArgumentException("cardId parameter is missing") + } + } + if (result.isSuccess) { + NoContent + } else { + val throwable = result.failed.get + throwable match { + case _: CantPlayCardException => + BadRequest(throwable.getMessage) + case _: NotInThisGameException => + BadRequest(throwable.getMessage) + case _: IllegalArgumentException => + BadRequest(throwable.getMessage) + case _: IllegalStateException => + BadRequest(throwable.getMessage) + case _ => + InternalServerError(throwable.getMessage) + } + } + } + case None => + NotFound("Game not found") + } + } + } + def playTrump(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val game = podManager.getGame(gameId) + game match { + case Some(g) => + val trumpOpt = request.body.asFormUrlEncoded.flatMap(_.get("trump").flatMap(_.headOption)) + trumpOpt match { + case Some(trump) => + val result = Try { + g.selectTrump(g.getUserSession(request.user.id), trump.toInt) + } + if (result.isSuccess) { + NoContent + } else { + val throwable = result.failed.get + throwable match { + case _: IllegalArgumentException => + BadRequest(throwable.getMessage) + case _: NotInThisGameException => + BadRequest(throwable.getMessage) + case _: IllegalStateException => + BadRequest(throwable.getMessage) + case _ => + InternalServerError(throwable.getMessage) + } + } + case None => + BadRequest("trump parameter is missing") + } + case None => + NotFound("Game not found") + } + } + def playTie(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val game = podManager.getGame(gameId) + game match { + case Some(g) => + val tieOpt = request.body.asFormUrlEncoded.flatMap(_.get("tie").flatMap(_.headOption)) + tieOpt match { + case Some(tie) => + val result = Try { + g.selectTie(g.getUserSession(request.user.id), tie.toInt) + } + if (result.isSuccess) { + NoContent + } else { + val throwable = result.failed.get + throwable match { + case _: IllegalArgumentException => + BadRequest(throwable.getMessage) + case _: NotInThisGameException => + BadRequest(throwable.getMessage) + case _: IllegalStateException => + BadRequest(throwable.getMessage) + case _ => + InternalServerError(throwable.getMessage) + } + } + case None => + BadRequest("tie parameter is missing") + } + case None => + NotFound("Game not found") + } + } + +} \ No newline at end of file diff --git a/knockoutwhistweb/app/controllers/MainMenuController.scala b/knockoutwhistweb/app/controllers/MainMenuController.scala new file mode 100644 index 0000000..a7a1637 --- /dev/null +++ b/knockoutwhistweb/app/controllers/MainMenuController.scala @@ -0,0 +1,45 @@ +package controllers + +import auth.{AuthAction, AuthenticatedRequest} +import logic.PodManager +import play.api.* +import play.api.mvc.* + +import javax.inject.* + + +/** + * This controller creates an `Action` to handle HTTP requests to the + * application's home page. + */ +@Singleton +class MainMenuController @Inject()( + val controllerComponents: ControllerComponents, + val authAction: AuthAction, + val podManager: PodManager + ) extends BaseController { + + // Pass the request-handling function directly to authAction (no nested Action) + def mainMenu(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + Ok("Main Menu for user: " + request.user.name) + } + + def index(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + Redirect(routes.MainMenuController.mainMenu()) + } + + def createGame(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val gameLobby = podManager.createGame( + host = request.user, + name = s"${request.user.name}'s Game", + maxPlayers = 4 + ) + Redirect(routes.IngameController.game(gameLobby.id)) + } + + def rules(): Action[AnyContent] = { + Action { implicit request => + Ok(views.html.mainmenu.rules()) + } + } +} \ No newline at end of file diff --git a/knockoutwhistweb/app/controllers/PodGameManager.scala b/knockoutwhistweb/app/controllers/PodGameManager.scala deleted file mode 100644 index fc9da31..0000000 --- a/knockoutwhistweb/app/controllers/PodGameManager.scala +++ /dev/null @@ -1,37 +0,0 @@ -package controllers - -import controllers.sessions.PlayerSession -import de.knockoutwhist.utils.events.SimpleEvent - -import java.util.UUID -import scala.collection.mutable - -object PodGameManager { - - private val sessions: mutable.Map[UUID, PlayerSession] = mutable.Map() - - def addSession(session: PlayerSession): Unit = { - sessions.put(session.id, session) - } - - def clearSessions(): Unit = { - sessions.clear() - } - - def identify(id: UUID): Option[PlayerSession] = { - sessions.get(id) - } - - def transmit(id: UUID, event: SimpleEvent): Unit = { - identify(id).foreach(_.updatePlayer(event)) - } - - def transmitAll(event: SimpleEvent): Unit = { - sessions.foreach(session => session._2.updatePlayer(event)) - } - - def listSessions(): List[PlayerSession] = { - sessions.values.toList - } - -} diff --git a/knockoutwhistweb/app/controllers/UserController.scala b/knockoutwhistweb/app/controllers/UserController.scala new file mode 100644 index 0000000..361826f --- /dev/null +++ b/knockoutwhistweb/app/controllers/UserController.scala @@ -0,0 +1,70 @@ +package controllers + +import auth.{AuthAction, AuthenticatedRequest} +import logic.user.{SessionManager, UserManager} +import play.api.* +import play.api.mvc.* + +import javax.inject.* + + +/** + * This controller creates an `Action` to handle HTTP requests to the + * application's home page. + */ +@Singleton +class UserController @Inject()( + val controllerComponents: ControllerComponents, + val sessionManager: SessionManager, + val userManager: UserManager, + val authAction: AuthAction + ) extends BaseController { + + def login(): Action[AnyContent] = { + Action { implicit request => + val session = request.cookies.get("sessionId") + if (session.isDefined) { + val possibleUser = sessionManager.getUserBySession(session.get.value) + if (possibleUser.isDefined) { + Redirect(routes.MainMenuController.mainMenu()) + } else { + Ok(views.html.login.login()) + } + } else { + Ok(views.html.login.login()) + } + } + } + + def login_Post(): Action[AnyContent] = { + Action { implicit request => + val postData = request.body.asFormUrlEncoded + if (postData.isDefined) { + // Extract username and password from form data + val username = postData.get.get("username").flatMap(_.headOption).getOrElse("") + val password = postData.get.get("password").flatMap(_.headOption).getOrElse("") + val possibleUser = userManager.authenticate(username, password) + if (possibleUser.isDefined) { + Redirect(routes.MainMenuController.mainMenu()).withCookies( + Cookie("sessionId", sessionManager.createSession(possibleUser.get)) + ) + } else { + println("Failed login attempt for user: " + username) + Unauthorized("Invalid username or password") + } + } else { + BadRequest("Invalid form submission") + } + } + } + + // Pass the request-handling function directly to authAction (no nested Action) + def logout(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val sessionCookie = request.cookies.get("sessionId") + if (sessionCookie.isDefined) { + sessionManager.invalidateSession(sessionCookie.get.value) + } + NoContent.discardingCookies(DiscardingCookie("sessionId")) + } + +} \ No newline at end of file diff --git a/knockoutwhistweb/app/controllers/WebUI.scala b/knockoutwhistweb/app/controllers/WebUI.scala deleted file mode 100644 index 726a1c4..0000000 --- a/knockoutwhistweb/app/controllers/WebUI.scala +++ /dev/null @@ -1,49 +0,0 @@ -package controllers - -import controllers.sessions.AdvancedSession -import de.knockoutwhist.cards.{Card, CardValue, Hand, Suit} -import de.knockoutwhist.control.GameLogic -import de.knockoutwhist.control.GameState.{InGame, Lobby} -import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic -import de.knockoutwhist.events.* -import de.knockoutwhist.events.global.GameStateChangeEvent -import de.knockoutwhist.player.AbstractPlayer -import de.knockoutwhist.rounds.Match -import de.knockoutwhist.ui.UI -import de.knockoutwhist.utils.CustomThread -import de.knockoutwhist.utils.events.{EventListener, SimpleEvent} - -object WebUI extends CustomThread with EventListener with UI { - - setName("WebUI") - - var init = false - var logic: Option[GameLogic] = None - - var latestOutput: String = "" - - override def instance: CustomThread = WebUI - - override def listen(event: SimpleEvent): Unit = { - event match { - case event: GameStateChangeEvent => - if (event.oldState == Lobby && event.newState == InGame) { - val match1: Option[Match] = logic.get.asInstanceOf[BaseGameLogic].getCurrentMatch - val players: List[AbstractPlayer] = match1.get.totalplayers - players.map(player => PodGameManager.addSession(AdvancedSession(player.id, player))) - } - case _ => - } - } - - override def initial(gameLogic: GameLogic): Boolean = { - if (init) { - return false - } - init = true - this.logic = Some(gameLogic) - start() - true - } - -} diff --git a/knockoutwhistweb/app/exceptions/CantPlayCardException.java b/knockoutwhistweb/app/exceptions/CantPlayCardException.java new file mode 100644 index 0000000..a313c07 --- /dev/null +++ b/knockoutwhistweb/app/exceptions/CantPlayCardException.java @@ -0,0 +1,7 @@ +package exceptions; + +public class CantPlayCardException extends GameException { + public CantPlayCardException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/exceptions/GameException.java b/knockoutwhistweb/app/exceptions/GameException.java new file mode 100644 index 0000000..5e001f2 --- /dev/null +++ b/knockoutwhistweb/app/exceptions/GameException.java @@ -0,0 +1,7 @@ +package exceptions; + +public abstract class GameException extends RuntimeException { + public GameException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/exceptions/GameFullException.java b/knockoutwhistweb/app/exceptions/GameFullException.java new file mode 100644 index 0000000..f0db380 --- /dev/null +++ b/knockoutwhistweb/app/exceptions/GameFullException.java @@ -0,0 +1,7 @@ +package exceptions; + +public class GameFullException extends GameException { + public GameFullException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/exceptions/NotEnoughPlayersException.java b/knockoutwhistweb/app/exceptions/NotEnoughPlayersException.java new file mode 100644 index 0000000..35c8d44 --- /dev/null +++ b/knockoutwhistweb/app/exceptions/NotEnoughPlayersException.java @@ -0,0 +1,7 @@ +package exceptions; + +public class NotEnoughPlayersException extends GameException { + public NotEnoughPlayersException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/exceptions/NotHostException.java b/knockoutwhistweb/app/exceptions/NotHostException.java new file mode 100644 index 0000000..423bbfd --- /dev/null +++ b/knockoutwhistweb/app/exceptions/NotHostException.java @@ -0,0 +1,7 @@ +package exceptions; + +public class NotHostException extends GameException { + public NotHostException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/exceptions/NotInThisGameException.java b/knockoutwhistweb/app/exceptions/NotInThisGameException.java new file mode 100644 index 0000000..fad03ed --- /dev/null +++ b/knockoutwhistweb/app/exceptions/NotInThisGameException.java @@ -0,0 +1,7 @@ +package exceptions; + +public class NotInThisGameException extends GameException { + public NotInThisGameException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/exceptions/NotInteractableException.java b/knockoutwhistweb/app/exceptions/NotInteractableException.java new file mode 100644 index 0000000..4c71ac7 --- /dev/null +++ b/knockoutwhistweb/app/exceptions/NotInteractableException.java @@ -0,0 +1,7 @@ +package exceptions; + +public class NotInteractableException extends GameException { + public NotInteractableException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/logic/PodManager.scala b/knockoutwhistweb/app/logic/PodManager.scala new file mode 100644 index 0000000..ad3e9c8 --- /dev/null +++ b/knockoutwhistweb/app/logic/PodManager.scala @@ -0,0 +1,49 @@ +package logic + +import com.google.inject.{Guice, Injector} +import de.knockoutwhist.components.Configuration +import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic +import di.KnockOutWebConfigurationModule +import logic.game.GameLobby +import model.users.User +import util.GameUtil + +import javax.inject.Singleton +import scala.collection.mutable + +@Singleton +class PodManager { + + val TTL: Long = System.currentTimeMillis() + 86400000L // 24 hours in milliseconds + val podIp: String = System.getenv("POD_IP") + val podName: String = System.getenv("POD_NAME") + + private val sessions: mutable.Map[String, GameLobby] = mutable.Map() + private val injector: Injector = Guice.createInjector(KnockOutWebConfigurationModule()) + + def createGame( + host: User, + name: String, + maxPlayers: Int + ): GameLobby = { + val gameLobby = GameLobby( + logic = BaseGameLogic(injector.getInstance(classOf[Configuration])), + id = GameUtil.generateCode(), + internalId = java.util.UUID.randomUUID(), + name = name, + maxPlayers = maxPlayers, + host = host + ) + sessions += (gameLobby.id -> gameLobby) + gameLobby + } + + def getGame(gameId: String): Option[GameLobby] = { + sessions.get(gameId) + } + + private[logic] def removeGame(gameId: String): Unit = { + sessions.remove(gameId) + } + +} diff --git a/knockoutwhistweb/app/logic/game/GameLobby.scala b/knockoutwhistweb/app/logic/game/GameLobby.scala new file mode 100644 index 0000000..7dadf4a --- /dev/null +++ b/knockoutwhistweb/app/logic/game/GameLobby.scala @@ -0,0 +1,246 @@ +package logic.game + +import de.knockoutwhist.cards.{Hand, Suit} +import de.knockoutwhist.control.GameLogic +import de.knockoutwhist.control.GameState.{Lobby, MainMenu} +import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.{MatchUtil, PlayerUtil} +import de.knockoutwhist.events.global.{GameStateChangeEvent, SessionClosed} +import de.knockoutwhist.events.player.PlayerEvent +import de.knockoutwhist.player.Playertype.HUMAN +import de.knockoutwhist.player.{AbstractPlayer, PlayerFactory} +import de.knockoutwhist.rounds.{Match, Round, Trick} +import de.knockoutwhist.utils.events.{EventListener, SimpleEvent} +import exceptions.* +import model.sessions.{InteractionType, UserSession} +import model.users.User + +import java.util.UUID +import scala.collection.mutable +import scala.collection.mutable.ListBuffer + +class GameLobby private( + val logic: GameLogic, + val id: String, + val internalId: UUID, + val name: String, + val maxPlayers: Int + ) extends EventListener { + logic.addListener(this) + logic.createSession() + + private val users: mutable.Map[UUID, UserSession] = mutable.Map() + + def addUser(user: User): UserSession = { + if (users.size >= maxPlayers) throw new GameFullException("The game is full!") + if (users.contains(user.id)) throw new IllegalArgumentException("User is already in the game!") + if (logic.getCurrentState != Lobby) throw new IllegalStateException("The game has already started!") + val userSession = new UserSession( + user = user, + host = false + ) + users += (user.id -> userSession) + userSession + } + + override def listen(event: SimpleEvent): Unit = { + event match { + case event: PlayerEvent => + users.get(event.playerId).foreach(session => session.updatePlayer(event)) + case event: GameStateChangeEvent => + if (event.oldState == MainMenu && event.newState == Lobby) { + return + } + users.values.foreach(session => session.updatePlayer(event)) + case event: SessionClosed => + users.values.foreach(session => session.updatePlayer(event)) + case event: SimpleEvent => + users.values.foreach(session => session.updatePlayer(event)) + } + } + + /** + * Start the game if the user is the host. + * @param user the user who wants to start the game. + */ + def startGame(user: User): Unit = { + val sessionOpt = users.get(user.id) + if (sessionOpt.isEmpty) { + throw new NotInThisGameException("You are not in this game!") + } + if (!sessionOpt.get.host) { + throw new NotHostException("Only the host can start the game!") + } + val playerNamesList = ListBuffer[AbstractPlayer]() + users.values.foreach { player => + playerNamesList += PlayerFactory.createPlayer(player.name, player.id, HUMAN) + } + if (playerNamesList.size < 2) { + throw new NotEnoughPlayersException("Not enough players to start the game!") + } + logic.createMatch(playerNamesList.toList) + logic.controlMatch() + } + + /** + * Remove the user from the game lobby. + * @param user the user who wants to leave the game. + */ + def leaveGame(user: User): Unit = { + val sessionOpt = users.get(user.id) + if (sessionOpt.isEmpty) { + throw new NotInThisGameException("You are not in this game!") + } + users.remove(user.id) + } + + /** + * Play a card from the player's hand. + * @param userSession the user session of the player. + * @param cardIndex the index of the card in the player's hand. + */ + def playCard(userSession: UserSession, cardIndex: Int): Unit = { + val player = getPlayerInteractable(userSession, InteractionType.Card) + if (player.isInDogLife) { + throw new CantPlayCardException("You are in dog life!") + } + val hand = getHand(player) + val card = hand.cards(cardIndex) + if (!PlayerUtil.canPlayCard(card, getRound, getTrick, player)) { + throw new CantPlayCardException("You can't play this card!") + } + logic.playerInputLogic.receivedCard(card) + } + + /** + * Play a card from the player's hand while in dog life or skip the round. + * @param userSession the user session of the player. + * @param cardIndex the index of the card in the player's hand or -1 if the player wants to skip the round. + */ + def playDogCard(userSession: UserSession, cardIndex: Int): Unit = { + val player = getPlayerInteractable(userSession, InteractionType.DogCard) + if (!player.isInDogLife) { + throw new CantPlayCardException("You are not in dog life!") + } + if (cardIndex == -1) { + if (!MatchUtil.dogNeedsToPlay(getMatch, getRound)) { + throw new CantPlayCardException("You can't skip this round!") + } + logic.playerInputLogic.receivedDog(None) + } + val hand = getHand(player) + val card = hand.cards(cardIndex) + logic.playerInputLogic.receivedDog(Some(card)) + } + + /** + * Select the trump suit for the round. + * @param userSession the user session of the player. + * @param trumpIndex the index of the trump suit. + */ + def selectTrump(userSession: UserSession, trumpIndex: Int): Unit = { + val player = getPlayerInteractable(userSession, InteractionType.TrumpSuit) + val trumpSuits = Suit.values.toList + val selectedTrump = trumpSuits(trumpIndex) + logic.playerInputLogic.receivedTrumpSuit(selectedTrump) + } + + /** + * + * @param userSession + * @param tieNumber + */ + def selectTie(userSession: UserSession, tieNumber: Int): Unit = { + val player = getPlayerInteractable(userSession, InteractionType.TieChoice) + logic.playerTieLogic.receivedTieBreakerCard(tieNumber) + } + + + //------------------- + + def getUserSession(userId: UUID): UserSession = { + val sessionOpt = users.get(userId) + if (sessionOpt.isEmpty) { + throw new NotInThisGameException("You are not in this game!") + } + sessionOpt.get + } + + def getPlayerByUser(user: User): AbstractPlayer = { + getPlayerBySession(getUserSession(user.id)) + } + + private def getPlayerBySession(userSession: UserSession): AbstractPlayer = { + val playerOption = getMatch.totalplayers.find(_.id == userSession.id) + if (playerOption.isEmpty) { + throw new NotInThisGameException("You are not in this game!") + } + playerOption.get + } + + private def getPlayerInteractable(userSession: UserSession, iType: InteractionType): AbstractPlayer = { + if (!Thread.holdsLock(userSession.lock)) { + throw new IllegalStateException("The user session is not locked!") + } + if (userSession.canInteract.isEmpty || userSession.canInteract.get != iType) { + throw new NotInteractableException("You can't play a card!") + } + getPlayerBySession(userSession) + } + + private def getHand(player: AbstractPlayer): Hand = { + val handOption = player.currentHand() + if (handOption.isEmpty) { + throw new IllegalStateException("You have no cards!") + } + handOption.get + } + + private def getMatch: Match = { + val matchOpt = logic.getCurrentMatch + if (matchOpt.isEmpty) { + throw new IllegalStateException("No match is currently running!") + } + matchOpt.get + } + + private def getRound: Round = { + val roundOpt = logic.getCurrentRound + if (roundOpt.isEmpty) { + throw new IllegalStateException("No round is currently running!") + } + roundOpt.get + } + + private def getTrick: Trick = { + val trickOpt = logic.getCurrentTrick + if (trickOpt.isEmpty) { + throw new IllegalStateException("No trick is currently running!") + } + trickOpt.get + } + +} + +object GameLobby { + def apply( + logic: GameLogic, + id: String, + internalId: UUID, + name: String, + maxPlayers: Int, + host: User + ): GameLobby = { + val lobby = new GameLobby( + logic = logic, + id = id, + internalId = internalId, + name = name, + maxPlayers = maxPlayers + ) + lobby.users += (host.id -> new UserSession( + user = host, + host = true + )) + lobby + } +} diff --git a/knockoutwhistweb/app/logic/user/SessionManager.scala b/knockoutwhistweb/app/logic/user/SessionManager.scala new file mode 100644 index 0000000..aad7472 --- /dev/null +++ b/knockoutwhistweb/app/logic/user/SessionManager.scala @@ -0,0 +1,14 @@ +package logic.user + +import com.google.inject.ImplementedBy +import logic.user.impl.BaseSessionManager +import model.users.User + +@ImplementedBy(classOf[BaseSessionManager]) +trait SessionManager { + + def createSession(user: User): String + def getUserBySession(sessionId: String): Option[User] + def invalidateSession(sessionId: String): Unit + +} diff --git a/knockoutwhistweb/app/logic/user/UserManager.scala b/knockoutwhistweb/app/logic/user/UserManager.scala new file mode 100644 index 0000000..ecf3a8d --- /dev/null +++ b/knockoutwhistweb/app/logic/user/UserManager.scala @@ -0,0 +1,16 @@ +package logic.user + +import com.google.inject.ImplementedBy +import logic.user.impl.StubUserManager +import model.users.User + +@ImplementedBy(classOf[StubUserManager]) +trait UserManager { + + def addUser(name: String, password: String): Boolean + def authenticate(name: String, password: String): Option[User] + def userExists(name: String): Option[User] + def userExistsById(id: Long): Option[User] + def removeUser(name: String): Boolean + +} diff --git a/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala b/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala new file mode 100644 index 0000000..1efe06c --- /dev/null +++ b/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala @@ -0,0 +1,63 @@ +package logic.user.impl + +import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.{JWT, JWTVerifier} +import com.github.benmanes.caffeine.cache.{Cache, Caffeine} +import com.typesafe.config.Config +import logic.user.SessionManager +import model.users.User +import scalafx.util.Duration +import services.JwtKeyProvider + +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.concurrent.TimeUnit +import javax.inject.{Inject, Singleton} + +@Singleton +class BaseSessionManager @Inject()(val keyProvider: JwtKeyProvider, val userManager: StubUserManager, val config: Config) extends SessionManager { + + private val algorithm = Algorithm.RSA512(keyProvider.publicKey, keyProvider.privateKey) + private val verifier: JWTVerifier = JWT.require(algorithm) + .withIssuer(config.getString("auth.issuer")) + .withAudience(config.getString("auth.audience")) + .build() + + //TODO reduce cache to a minimum amount, as JWT should be self-contained + private val cache: Cache[String, User] = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(5, TimeUnit.MINUTES).build() + + override def createSession(user: User): String = { + //Write session identifier to cache and DB + val sessionId = JWT.create() + .withIssuer(config.getString("auth.issuer")) + .withAudience(config.getString("auth.audience")) + .withSubject(user.id.toString) + .withClaim("id", user.internalId) + .withExpiresAt(Instant.now.plus(7, ChronoUnit.DAYS)) + .sign(algorithm) + //TODO write to Redis and DB + cache.put(sessionId, user) + + sessionId + } + + override def getUserBySession(sessionId: String): Option[User] = { + //TODO verify JWT token instead of looking up in cache + val cachedUser = cache.getIfPresent(sessionId) + if (cachedUser != null) { + Some(cachedUser) + } else { + val decoded = verifier.verify(sessionId) + val user = userManager.userExistsById(decoded.getClaim("id").asLong()) + user.foreach(u => cache.put(sessionId, u)) + user + } + } + + override def invalidateSession(sessionId: String): Unit = { + //TODO remove from Redis and DB + cache.invalidate(sessionId) + } +} diff --git a/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala b/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala new file mode 100644 index 0000000..44f71ee --- /dev/null +++ b/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala @@ -0,0 +1,51 @@ +package logic.user.impl + +import com.typesafe.config.Config +import logic.user.UserManager +import model.users.User +import util.UserHash + +import javax.inject.{Inject, Singleton} + +@Singleton +class StubUserManager @Inject()(val config: Config) extends UserManager { + + private val user: Map[String, User] = Map( + "Janis" -> User( + internalId = 1L, + id = java.util.UUID.fromString("123e4567-e89b-12d3-a456-426614174000"), + name = "Janis", + passwordHash = UserHash.hashPW("password123") + ), + "Leon" -> User( + internalId = 2L, + id = java.util.UUID.fromString("223e4567-e89b-12d3-a456-426614174000"), + name = "Leon", + passwordHash = UserHash.hashPW("password123") + ) + ) + + override def addUser(name: String, password: String): Boolean = { + throw new NotImplementedError("StubUserManager.addUser is not implemented") + } + + override def authenticate(name: String, password: String): Option[User] = { + user.get(name) match { + case Some(u) if UserHash.verifyUser(password, u) => Some(u) + case _ => None + } + } + + override def userExists(name: String): Option[User] = { + user.get(name) + } + + override def userExistsById(id: Long): Option[User] = { + user.values.find(_.internalId == id) + } + + override def removeUser(name: String): Boolean = { + throw new NotImplementedError("StubUserManager.removeUser is not implemented") + } + +} diff --git a/knockoutwhistweb/app/model/sessions/InteractionType.scala b/knockoutwhistweb/app/model/sessions/InteractionType.scala new file mode 100644 index 0000000..e265edb --- /dev/null +++ b/knockoutwhistweb/app/model/sessions/InteractionType.scala @@ -0,0 +1,10 @@ +package model.sessions + +enum InteractionType { + + case TrumpSuit + case Card + case DogCard + case TieChoice + +} \ No newline at end of file diff --git a/knockoutwhistweb/app/controllers/sessions/PlayerSession.scala b/knockoutwhistweb/app/model/sessions/PlayerSession.scala similarity index 86% rename from knockoutwhistweb/app/controllers/sessions/PlayerSession.scala rename to knockoutwhistweb/app/model/sessions/PlayerSession.scala index e76ddc2..95c39f5 100644 --- a/knockoutwhistweb/app/controllers/sessions/PlayerSession.scala +++ b/knockoutwhistweb/app/model/sessions/PlayerSession.scala @@ -1,4 +1,4 @@ -package controllers.sessions +package model.sessions import de.knockoutwhist.utils.events.SimpleEvent diff --git a/knockoutwhistweb/app/controllers/sessions/AdvancedSession.scala b/knockoutwhistweb/app/model/sessions/SimpleSession.scala similarity index 66% rename from knockoutwhistweb/app/controllers/sessions/AdvancedSession.scala rename to knockoutwhistweb/app/model/sessions/SimpleSession.scala index 770e3fb..a4c7007 100644 --- a/knockoutwhistweb/app/controllers/sessions/AdvancedSession.scala +++ b/knockoutwhistweb/app/model/sessions/SimpleSession.scala @@ -1,11 +1,11 @@ -package controllers.sessions +package model.sessions import de.knockoutwhist.player.AbstractPlayer import de.knockoutwhist.utils.events.SimpleEvent import java.util.UUID -case class AdvancedSession(id: UUID, player: AbstractPlayer) extends PlayerSession { +case class SimpleSession(id: UUID, player: AbstractPlayer) extends PlayerSession { def name: String = player.name diff --git a/knockoutwhistweb/app/model/sessions/UserSession.scala b/knockoutwhistweb/app/model/sessions/UserSession.scala new file mode 100644 index 0000000..df45464 --- /dev/null +++ b/knockoutwhistweb/app/model/sessions/UserSession.scala @@ -0,0 +1,31 @@ +package model.sessions + +import de.knockoutwhist.events.player.{RequestCardEvent, RequestTieChoiceEvent, RequestTrumpSuitEvent} +import de.knockoutwhist.utils.events.SimpleEvent +import model.users.User + +import java.util.UUID +import java.util.concurrent.locks.{Lock, ReentrantLock} + +class UserSession(user: User, val host: Boolean) extends PlayerSession { + var canInteract: Option[InteractionType] = None + val lock: Lock = ReentrantLock() + + override def updatePlayer(event: SimpleEvent): Unit = { + event match { + case event: RequestTrumpSuitEvent => + canInteract = Some(InteractionType.TrumpSuit) + case event: RequestTieChoiceEvent => + canInteract = Some(InteractionType.TieChoice) + case event: RequestCardEvent => + if (event.player.isInDogLife) canInteract = Some(InteractionType.DogCard) + else canInteract = Some(InteractionType.Card) + case _ => + } + } + + override def id: UUID = user.id + + override def name: String = user.name + +} diff --git a/knockoutwhistweb/app/model/users/User.scala b/knockoutwhistweb/app/model/users/User.scala new file mode 100644 index 0000000..e56c048 --- /dev/null +++ b/knockoutwhistweb/app/model/users/User.scala @@ -0,0 +1,20 @@ +package model.users + +import java.util.UUID + +case class User( + internalId: Long, + id: UUID, + name: String, + passwordHash: String + ) { + + def withName(newName: String): User = { + this.copy(name = newName) + } + + private def withPasswordHash(newPasswordHash: String): User = { + this.copy(passwordHash = newPasswordHash) + } + +} diff --git a/knockoutwhistweb/app/services/JwtKeyProvider.scala b/knockoutwhistweb/app/services/JwtKeyProvider.scala new file mode 100644 index 0000000..f1fb46f --- /dev/null +++ b/knockoutwhistweb/app/services/JwtKeyProvider.scala @@ -0,0 +1,56 @@ +package services + +import play.api.Configuration + +import java.nio.file.{Files, Paths} +import java.security.interfaces.{RSAPrivateKey, RSAPublicKey} +import java.security.spec.{PKCS8EncodedKeySpec, RSAPublicKeySpec, X509EncodedKeySpec} +import java.security.{KeyFactory, KeyPair, PrivateKey, PublicKey} +import java.util.Base64 +import javax.inject.* + +@Singleton +class JwtKeyProvider @Inject()(config: Configuration) { + + private def cleanPem(pem: String): String = + pem.replaceAll("-----BEGIN (.*)-----", "") + .replaceAll("-----END (.*)-----", "") + .replaceAll("\\s", "") + + private def loadPublicKeyFromPem(pem: String): RSAPublicKey = { + val decoded = Base64.getDecoder.decode(cleanPem(pem)) + val spec = new X509EncodedKeySpec(decoded) + KeyFactory.getInstance("RSA").generatePublic(spec).asInstanceOf[RSAPublicKey] + } + + private def loadPrivateKeyFromPem(pem: String): RSAPrivateKey = { + val decoded = Base64.getDecoder.decode(cleanPem(pem)) + val spec = new PKCS8EncodedKeySpec(decoded) + KeyFactory.getInstance("RSA").generatePrivate(spec).asInstanceOf[RSAPrivateKey] + } + + val publicKey: RSAPublicKey = { + val pemOpt = config.getOptional[String]("auth.publicKeyPem") + val fileOpt = config.getOptional[String]("auth.publicKeyFile") + + pemOpt.orElse(fileOpt.map { path => + new String(Files.readAllBytes(Paths.get(path))) + }) match { + case Some(pem) => loadPublicKeyFromPem(pem) + case None => throw new RuntimeException("No RSA public key configured.") + } + } + + val privateKey: RSAPrivateKey = { + val pemOpt = config.getOptional[String]("auth.privateKeyPem") + val fileOpt = config.getOptional[String]("auth.privateKeyFile") + + pemOpt.orElse(fileOpt.map { path => + new String(Files.readAllBytes(Paths.get(path))) + }) match { + case Some(pem) => loadPrivateKeyFromPem(pem) + case None => throw new RuntimeException("No RSA private key configured.") + } + } + +} diff --git a/knockoutwhistweb/app/util/GameUtil.scala b/knockoutwhistweb/app/util/GameUtil.scala new file mode 100644 index 0000000..b2caf50 --- /dev/null +++ b/knockoutwhistweb/app/util/GameUtil.scala @@ -0,0 +1,29 @@ +package util + +import scala.util.Random + +object GameUtil { + + private val CharPool: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + private val CodeLength: Int = 6 + private val MaxRepetition: Int = 2 + private val random = new Random() + + def generateCode(): String = { + val freq = Array.fill(CharPool.length)(0) + val code = new StringBuilder(CodeLength) + + for (_ <- 0 until CodeLength) { + var index = random.nextInt(CharPool.length) + // Pick a new character if it's already used twice + while (freq(index) >= MaxRepetition) { + index = random.nextInt(CharPool.length) + } + freq(index) += 1 + code.append(CharPool.charAt(index)) + } + + code.toString() + } + +} diff --git a/knockoutwhistweb/app/util/UserHash.scala b/knockoutwhistweb/app/util/UserHash.scala new file mode 100644 index 0000000..1c48cab --- /dev/null +++ b/knockoutwhistweb/app/util/UserHash.scala @@ -0,0 +1,23 @@ +package util + +import de.mkammerer.argon2.Argon2Factory +import de.mkammerer.argon2.Argon2Factory.Argon2Types +import model.users.User + +object UserHash { + private val ITERATIONS: Int = 3 + private val MEMORY: Int = 32_768 + private val PARALLELISM: Int = 1 + private val SALT_LENGTH: Int = 32 + private val HASH_LENGTH: Int = 64 + private val ARGON_2 = Argon2Factory.create(Argon2Types.ARGON2id, SALT_LENGTH, HASH_LENGTH) + + def hashPW(password: String): String = { + ARGON_2.hash(ITERATIONS, MEMORY, PARALLELISM, password.toCharArray) + } + + def verifyUser(password: String, user: User): Boolean = { + ARGON_2.verify(user.passwordHash, password.toCharArray) + } + +} diff --git a/knockoutwhistweb/app/util/WebUIUtils.scala b/knockoutwhistweb/app/util/WebUIUtils.scala index 84e0558..b905462 100644 --- a/knockoutwhistweb/app/util/WebUIUtils.scala +++ b/knockoutwhistweb/app/util/WebUIUtils.scala @@ -29,6 +29,6 @@ object WebUIUtils { case Three => "3" case Two => "2" } - views.html.output.card.apply(f"images/cards/$cv$s.png")(card.toString) + views.html.render.card.apply(f"images/cards/$cv$s.png")(card.toString) } } diff --git a/knockoutwhistweb/app/views/index.scala.html b/knockoutwhistweb/app/views/index.scala.html deleted file mode 100644 index d4fdd74..0000000 --- a/knockoutwhistweb/app/views/index.scala.html +++ /dev/null @@ -1,3 +0,0 @@ -@main("Welcome to Play") { -

Welcome to Play!

-} diff --git a/knockoutwhistweb/app/views/ingame.scala.html b/knockoutwhistweb/app/views/ingame/ingame.scala.html similarity index 87% rename from knockoutwhistweb/app/views/ingame.scala.html rename to knockoutwhistweb/app/views/ingame/ingame.scala.html index 379f219..cb24ac0 100644 --- a/knockoutwhistweb/app/views/ingame.scala.html +++ b/knockoutwhistweb/app/views/ingame/ingame.scala.html @@ -1,7 +1,7 @@ -@(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic) +@(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.GameLogic) @main("Ingame") { -
+

Knockout Whist

Next Player:

@@ -17,7 +17,7 @@ @if(logic.getCurrentTrick.get.firstCard.isDefined) { @util.WebUIUtils.cardtoImage(logic.getCurrentTrick.get.firstCard.get) } else { - @views.html.output.card.apply("images/cards/1B.png")("Blank Card") + @views.html.render.card.apply("../../../public/images/cards/1B.png")("Blank Card") }
diff --git a/knockoutwhistweb/app/views/selecttrump.scala.html b/knockoutwhistweb/app/views/ingame/selecttrump.scala.html similarity index 93% rename from knockoutwhistweb/app/views/selecttrump.scala.html rename to knockoutwhistweb/app/views/ingame/selecttrump.scala.html index 6cf8851..8aef1e3 100644 --- a/knockoutwhistweb/app/views/selecttrump.scala.html +++ b/knockoutwhistweb/app/views/ingame/selecttrump.scala.html @@ -1,7 +1,7 @@ -@(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic) +@(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.GameLogic) @main("Selecting Trumpsuit...") { -
+
@if(player.equals(logic.getCurrentMatch.get.roundlist.last.winner.get)) {

Knockout Whist

You (@player.toString) have won the last round! Select a trumpsuit for the next round!

diff --git a/knockoutwhistweb/app/views/tie.scala.html b/knockoutwhistweb/app/views/ingame/tie.scala.html similarity index 91% rename from knockoutwhistweb/app/views/tie.scala.html rename to knockoutwhistweb/app/views/ingame/tie.scala.html index 75b74aa..505c68d 100644 --- a/knockoutwhistweb/app/views/tie.scala.html +++ b/knockoutwhistweb/app/views/ingame/tie.scala.html @@ -1,7 +1,7 @@ -@(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic) +@(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.GameLogic) @main("Tie") { -
+

Knockout Whist

The last Round was tied between @for(players <- logic.playerTieLogic.getTiedPlayers) { diff --git a/knockoutwhistweb/app/views/login/login.scala.html b/knockoutwhistweb/app/views/login/login.scala.html new file mode 100644 index 0000000..219707e --- /dev/null +++ b/knockoutwhistweb/app/views/login/login.scala.html @@ -0,0 +1,41 @@ +@() + +@main("Login") { +

+ +
+} \ No newline at end of file diff --git a/knockoutwhistweb/app/views/main.scala.html b/knockoutwhistweb/app/views/main.scala.html index f3d0d99..6a535d9 100644 --- a/knockoutwhistweb/app/views/main.scala.html +++ b/knockoutwhistweb/app/views/main.scala.html @@ -13,6 +13,8 @@ @title + + @@ -20,6 +22,7 @@ * the page content. *@ @content - + + diff --git a/knockoutwhistweb/app/views/rules.scala.html b/knockoutwhistweb/app/views/mainmenu/rules.scala.html similarity index 100% rename from knockoutwhistweb/app/views/rules.scala.html rename to knockoutwhistweb/app/views/mainmenu/rules.scala.html diff --git a/knockoutwhistweb/app/views/output/card.scala.html b/knockoutwhistweb/app/views/render/card.scala.html similarity index 100% rename from knockoutwhistweb/app/views/output/card.scala.html rename to knockoutwhistweb/app/views/render/card.scala.html diff --git a/knockoutwhistweb/app/views/output/text.scala.html b/knockoutwhistweb/app/views/render/text.scala.html similarity index 100% rename from knockoutwhistweb/app/views/output/text.scala.html rename to knockoutwhistweb/app/views/render/text.scala.html diff --git a/knockoutwhistweb/app/views/sessions.scala.html b/knockoutwhistweb/app/views/sessions.scala.html deleted file mode 100644 index 8be9656..0000000 --- a/knockoutwhistweb/app/views/sessions.scala.html +++ /dev/null @@ -1,12 +0,0 @@ -@(sessions: List[controllers.sessions.PlayerSession]) - -@main("Sessions") { -
-

Knockout Whist sessions

-

Please select your session to jump inside the game!

- @for(session <- sessions) { - @session.name
- } -
-} - diff --git a/knockoutwhistweb/app/views/tui.scala.html b/knockoutwhistweb/app/views/tui.scala.html deleted file mode 100644 index 8d8cc04..0000000 --- a/knockoutwhistweb/app/views/tui.scala.html +++ /dev/null @@ -1,10 +0,0 @@ -@(toRender: List[Html]) - -@main("Tui") { -
- @for(line <- toRender) { - @line - } -
-} - diff --git a/knockoutwhistweb/conf/application.conf b/knockoutwhistweb/conf/application.conf index cb94680..d6d372a 100644 --- a/knockoutwhistweb/conf/application.conf +++ b/knockoutwhistweb/conf/application.conf @@ -1 +1,14 @@ # https://www.playframework.com/documentation/latest/Configuration +play.filters.disabled += play.filters.csrf.CSRFFilter + + +auth { + issuer = "knockoutwhistweb" + audience = "ui" + # ${?PUBLIC_KEY_FILE} + privateKeyFile = "D:\\Workspaces\\Gitops\\rsa512-private.pem" + privateKeyPem = ${?PUBLIC_KEY_PEM} + #${?PUBLIC_KEY_FILE} + publicKeyFile = "D:\\Workspaces\\Gitops\\rsa512-public.pem" + publicKeyPem = ${?PUBLIC_KEY_PEM} +} diff --git a/knockoutwhistweb/conf/routes b/knockoutwhistweb/conf/routes index 3d2cb6d..c122c96 100644 --- a/knockoutwhistweb/conf/routes +++ b/knockoutwhistweb/conf/routes @@ -3,12 +3,28 @@ # https://www.playframework.com/documentation/latest/ScalaRouting # ~~~~ -# An example controller showing a sample home page -GET / controllers.HomeController.index() -GET /sessions controllers.HomeController.sessions() -GET /ingame/:id controllers.HomeController.ingame(id: String) -# Map static resources from the /public folder to the /assets URL path +# Primary routes +GET / controllers.MainMenuController.index() GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset) -GET /rules controllers.HomeController.rules() +# Main menu routes +GET /mainmenu controllers.MainMenuController.mainMenu() +GET /rules controllers.MainMenuController.rules() + +POST /createGame controllers.MainMenuController.createGame() + +# User authentication routes +GET /login controllers.UserController.login() +POST /login controllers.UserController.login_Post() + +GET /logout controllers.UserController.logout() + +# In-game routes +GET /game/:id controllers.IngameController.game(id: String) +POST /game/:id/join controllers.IngameController.joinGame(id: String) + +POST /game/:id/start controllers.IngameController.startGame(id: String) + + +POST /game/:id/playCard controllers.IngameController.playCard(id: String) \ No newline at end of file diff --git a/knockoutwhistweb/public/conf/particlesjs-config.json b/knockoutwhistweb/public/conf/particlesjs-config.json new file mode 100644 index 0000000..e0215db --- /dev/null +++ b/knockoutwhistweb/public/conf/particlesjs-config.json @@ -0,0 +1,110 @@ +{ + "particles": { + "number": { + "value": 80, + "density": { + "enable": true, + "value_area": 800 + } + }, + "color": { + "value": "#ffffff" + }, + "shape": { + "type": "circle", + "stroke": { + "width": 0, + "color": "#000000" + }, + "polygon": { + "nb_sides": 5 + }, + "image": { + "src": "img/github.svg", + "width": 100, + "height": 100 + } + }, + "opacity": { + "value": 0.5, + "random": false, + "anim": { + "enable": false, + "speed": 1, + "opacity_min": 0.1, + "sync": false + } + }, + "size": { + "value": 3, + "random": true, + "anim": { + "enable": false, + "speed": 40, + "size_min": 0.1, + "sync": false + } + }, + "line_linked": { + "enable": true, + "distance": 150, + "color": "#ffffff", + "opacity": 0.4, + "width": 1 + }, + "move": { + "enable": true, + "speed": 1, + "direction": "none", + "random": false, + "straight": false, + "out_mode": "out", + "bounce": false, + "attract": { + "enable": false, + "rotateX": 600, + "rotateY": 1200 + } + } + }, + "interactivity": { + "detect_on": "canvas", + "events": { + "onhover": { + "enable": false, + "mode": "repulse" + }, + "onclick": { + "enable": false, + "mode": "push" + }, + "resize": true + }, + "modes": { + "grab": { + "distance": 400, + "line_linked": { + "opacity": 1 + } + }, + "bubble": { + "distance": 400, + "size": 40, + "duration": 2, + "opacity": 8, + "speed": 3 + }, + "repulse": { + "distance": 200, + "duration": 0.4 + }, + "push": { + "particles_nb": 4 + }, + "remove": { + "particles_nb": 2 + } + } + }, + "retina_detect": true +} \ No newline at end of file diff --git a/knockoutwhistweb/public/javascripts/main.js b/knockoutwhistweb/public/javascripts/main.js index e69de29..2474284 100644 --- a/knockoutwhistweb/public/javascripts/main.js +++ b/knockoutwhistweb/public/javascripts/main.js @@ -0,0 +1,3 @@ +particlesJS.load('particles-js', 'assets/conf/particlesjs-config.json', function() { + console.log('callback - particles.js config loaded'); +}); \ No newline at end of file diff --git a/knockoutwhistweb/public/javascripts/particles.js b/knockoutwhistweb/public/javascripts/particles.js new file mode 100644 index 0000000..325d834 --- /dev/null +++ b/knockoutwhistweb/public/javascripts/particles.js @@ -0,0 +1,1541 @@ +/* ----------------------------------------------- +/* Author : Vincent Garreau - vincentgarreau.com +/* MIT license: http://opensource.org/licenses/MIT +/* Demo / Generator : vincentgarreau.com/particles.js +/* GitHub : github.com/VincentGarreau/particles.js +/* How to use? : Check the GitHub README +/* v2.0.0 +/* ----------------------------------------------- */ + +var pJS = function(tag_id, params){ + + var canvas_el = document.querySelector('#'+tag_id+' > .particles-js-canvas-el'); + + /* particles.js variables with default values */ + this.pJS = { + canvas: { + el: canvas_el, + w: canvas_el.offsetWidth, + h: canvas_el.offsetHeight + }, + particles: { + number: { + value: 400, + density: { + enable: true, + value_area: 800 + } + }, + color: { + value: '#fff' + }, + shape: { + type: 'circle', + stroke: { + width: 0, + color: '#ff0000' + }, + polygon: { + nb_sides: 5 + }, + image: { + src: '', + width: 100, + height: 100 + } + }, + opacity: { + value: 1, + random: false, + anim: { + enable: false, + speed: 2, + opacity_min: 0, + sync: false + } + }, + size: { + value: 20, + random: false, + anim: { + enable: false, + speed: 20, + size_min: 0, + sync: false + } + }, + line_linked: { + enable: true, + distance: 100, + color: '#fff', + opacity: 1, + width: 1 + }, + move: { + enable: true, + speed: 2, + direction: 'none', + random: false, + straight: false, + out_mode: 'out', + bounce: false, + attract: { + enable: false, + rotateX: 3000, + rotateY: 3000 + } + }, + array: [] + }, + interactivity: { + detect_on: 'canvas', + events: { + onhover: { + enable: true, + mode: 'grab' + }, + onclick: { + enable: true, + mode: 'push' + }, + resize: true + }, + modes: { + grab:{ + distance: 100, + line_linked:{ + opacity: 1 + } + }, + bubble:{ + distance: 200, + size: 80, + duration: 0.4 + }, + repulse:{ + distance: 200, + duration: 0.4 + }, + push:{ + particles_nb: 4 + }, + remove:{ + particles_nb: 2 + } + }, + mouse:{} + }, + retina_detect: false, + fn: { + interact: {}, + modes: {}, + vendors:{} + }, + tmp: {} + }; + + var pJS = this.pJS; + + /* params settings */ + if(params){ + Object.deepExtend(pJS, params); + } + + pJS.tmp.obj = { + size_value: pJS.particles.size.value, + size_anim_speed: pJS.particles.size.anim.speed, + move_speed: pJS.particles.move.speed, + line_linked_distance: pJS.particles.line_linked.distance, + line_linked_width: pJS.particles.line_linked.width, + mode_grab_distance: pJS.interactivity.modes.grab.distance, + mode_bubble_distance: pJS.interactivity.modes.bubble.distance, + mode_bubble_size: pJS.interactivity.modes.bubble.size, + mode_repulse_distance: pJS.interactivity.modes.repulse.distance + }; + + + pJS.fn.retinaInit = function(){ + + if(pJS.retina_detect && window.devicePixelRatio > 1){ + pJS.canvas.pxratio = window.devicePixelRatio; + pJS.tmp.retina = true; + } + else{ + pJS.canvas.pxratio = 1; + pJS.tmp.retina = false; + } + + pJS.canvas.w = pJS.canvas.el.offsetWidth * pJS.canvas.pxratio; + pJS.canvas.h = pJS.canvas.el.offsetHeight * pJS.canvas.pxratio; + + pJS.particles.size.value = pJS.tmp.obj.size_value * pJS.canvas.pxratio; + pJS.particles.size.anim.speed = pJS.tmp.obj.size_anim_speed * pJS.canvas.pxratio; + pJS.particles.move.speed = pJS.tmp.obj.move_speed * pJS.canvas.pxratio; + pJS.particles.line_linked.distance = pJS.tmp.obj.line_linked_distance * pJS.canvas.pxratio; + pJS.interactivity.modes.grab.distance = pJS.tmp.obj.mode_grab_distance * pJS.canvas.pxratio; + pJS.interactivity.modes.bubble.distance = pJS.tmp.obj.mode_bubble_distance * pJS.canvas.pxratio; + pJS.particles.line_linked.width = pJS.tmp.obj.line_linked_width * pJS.canvas.pxratio; + pJS.interactivity.modes.bubble.size = pJS.tmp.obj.mode_bubble_size * pJS.canvas.pxratio; + pJS.interactivity.modes.repulse.distance = pJS.tmp.obj.mode_repulse_distance * pJS.canvas.pxratio; + + }; + + + + /* ---------- pJS functions - canvas ------------ */ + + pJS.fn.canvasInit = function(){ + pJS.canvas.ctx = pJS.canvas.el.getContext('2d'); + }; + + pJS.fn.canvasSize = function(){ + + pJS.canvas.el.width = pJS.canvas.w; + pJS.canvas.el.height = pJS.canvas.h; + + if(pJS && pJS.interactivity.events.resize){ + + window.addEventListener('resize', function(){ + + pJS.canvas.w = pJS.canvas.el.offsetWidth; + pJS.canvas.h = pJS.canvas.el.offsetHeight; + + /* resize canvas */ + if(pJS.tmp.retina){ + pJS.canvas.w *= pJS.canvas.pxratio; + pJS.canvas.h *= pJS.canvas.pxratio; + } + + pJS.canvas.el.width = pJS.canvas.w; + pJS.canvas.el.height = pJS.canvas.h; + + /* repaint canvas on anim disabled */ + if(!pJS.particles.move.enable){ + pJS.fn.particlesEmpty(); + pJS.fn.particlesCreate(); + pJS.fn.particlesDraw(); + pJS.fn.vendors.densityAutoParticles(); + } + + /* density particles enabled */ + pJS.fn.vendors.densityAutoParticles(); + + }); + + } + + }; + + + pJS.fn.canvasPaint = function(){ + pJS.canvas.ctx.fillRect(0, 0, pJS.canvas.w, pJS.canvas.h); + }; + + pJS.fn.canvasClear = function(){ + pJS.canvas.ctx.clearRect(0, 0, pJS.canvas.w, pJS.canvas.h); + }; + + + /* --------- pJS functions - particles ----------- */ + + pJS.fn.particle = function(color, opacity, position){ + + /* size */ + this.radius = (pJS.particles.size.random ? Math.random() : 1) * pJS.particles.size.value; + if(pJS.particles.size.anim.enable){ + this.size_status = false; + this.vs = pJS.particles.size.anim.speed / 100; + if(!pJS.particles.size.anim.sync){ + this.vs = this.vs * Math.random(); + } + } + + /* position */ + this.x = position ? position.x : Math.random() * pJS.canvas.w; + this.y = position ? position.y : Math.random() * pJS.canvas.h; + + /* check position - into the canvas */ + if(this.x > pJS.canvas.w - this.radius*2) this.x = this.x - this.radius; + else if(this.x < this.radius*2) this.x = this.x + this.radius; + if(this.y > pJS.canvas.h - this.radius*2) this.y = this.y - this.radius; + else if(this.y < this.radius*2) this.y = this.y + this.radius; + + /* check position - avoid overlap */ + if(pJS.particles.move.bounce){ + pJS.fn.vendors.checkOverlap(this, position); + } + + /* color */ + this.color = {}; + if(typeof(color.value) == 'object'){ + + if(color.value instanceof Array){ + var color_selected = color.value[Math.floor(Math.random() * pJS.particles.color.value.length)]; + this.color.rgb = hexToRgb(color_selected); + }else{ + if(color.value.r != undefined && color.value.g != undefined && color.value.b != undefined){ + this.color.rgb = { + r: color.value.r, + g: color.value.g, + b: color.value.b + } + } + if(color.value.h != undefined && color.value.s != undefined && color.value.l != undefined){ + this.color.hsl = { + h: color.value.h, + s: color.value.s, + l: color.value.l + } + } + } + + } + else if(color.value == 'random'){ + this.color.rgb = { + r: (Math.floor(Math.random() * (255 - 0 + 1)) + 0), + g: (Math.floor(Math.random() * (255 - 0 + 1)) + 0), + b: (Math.floor(Math.random() * (255 - 0 + 1)) + 0) + } + } + else if(typeof(color.value) == 'string'){ + this.color = color; + this.color.rgb = hexToRgb(this.color.value); + } + + /* opacity */ + this.opacity = (pJS.particles.opacity.random ? Math.random() : 1) * pJS.particles.opacity.value; + if(pJS.particles.opacity.anim.enable){ + this.opacity_status = false; + this.vo = pJS.particles.opacity.anim.speed / 100; + if(!pJS.particles.opacity.anim.sync){ + this.vo = this.vo * Math.random(); + } + } + + /* animation - velocity for speed */ + var velbase = {} + switch(pJS.particles.move.direction){ + case 'top': + velbase = { x:0, y:-1 }; + break; + case 'top-right': + velbase = { x:0.5, y:-0.5 }; + break; + case 'right': + velbase = { x:1, y:-0 }; + break; + case 'bottom-right': + velbase = { x:0.5, y:0.5 }; + break; + case 'bottom': + velbase = { x:0, y:1 }; + break; + case 'bottom-left': + velbase = { x:-0.5, y:1 }; + break; + case 'left': + velbase = { x:-1, y:0 }; + break; + case 'top-left': + velbase = { x:-0.5, y:-0.5 }; + break; + default: + velbase = { x:0, y:0 }; + break; + } + + if(pJS.particles.move.straight){ + this.vx = velbase.x; + this.vy = velbase.y; + if(pJS.particles.move.random){ + this.vx = this.vx * (Math.random()); + this.vy = this.vy * (Math.random()); + } + }else{ + this.vx = velbase.x + Math.random()-0.5; + this.vy = velbase.y + Math.random()-0.5; + } + + // var theta = 2.0 * Math.PI * Math.random(); + // this.vx = Math.cos(theta); + // this.vy = Math.sin(theta); + + this.vx_i = this.vx; + this.vy_i = this.vy; + + + + /* if shape is image */ + + var shape_type = pJS.particles.shape.type; + if(typeof(shape_type) == 'object'){ + if(shape_type instanceof Array){ + var shape_selected = shape_type[Math.floor(Math.random() * shape_type.length)]; + this.shape = shape_selected; + } + }else{ + this.shape = shape_type; + } + + if(this.shape == 'image'){ + var sh = pJS.particles.shape; + this.img = { + src: sh.image.src, + ratio: sh.image.width / sh.image.height + } + if(!this.img.ratio) this.img.ratio = 1; + if(pJS.tmp.img_type == 'svg' && pJS.tmp.source_svg != undefined){ + pJS.fn.vendors.createSvgImg(this); + if(pJS.tmp.pushing){ + this.img.loaded = false; + } + } + } + + + + }; + + + pJS.fn.particle.prototype.draw = function() { + + var p = this; + + if(p.radius_bubble != undefined){ + var radius = p.radius_bubble; + }else{ + var radius = p.radius; + } + + if(p.opacity_bubble != undefined){ + var opacity = p.opacity_bubble; + }else{ + var opacity = p.opacity; + } + + if(p.color.rgb){ + var color_value = 'rgba('+p.color.rgb.r+','+p.color.rgb.g+','+p.color.rgb.b+','+opacity+')'; + }else{ + var color_value = 'hsla('+p.color.hsl.h+','+p.color.hsl.s+'%,'+p.color.hsl.l+'%,'+opacity+')'; + } + + pJS.canvas.ctx.fillStyle = color_value; + pJS.canvas.ctx.beginPath(); + + switch(p.shape){ + + case 'circle': + pJS.canvas.ctx.arc(p.x, p.y, radius, 0, Math.PI * 2, false); + break; + + case 'edge': + pJS.canvas.ctx.rect(p.x-radius, p.y-radius, radius*2, radius*2); + break; + + case 'triangle': + pJS.fn.vendors.drawShape(pJS.canvas.ctx, p.x-radius, p.y+radius / 1.66, radius*2, 3, 2); + break; + + case 'polygon': + pJS.fn.vendors.drawShape( + pJS.canvas.ctx, + p.x - radius / (pJS.particles.shape.polygon.nb_sides/3.5), // startX + p.y - radius / (2.66/3.5), // startY + radius*2.66 / (pJS.particles.shape.polygon.nb_sides/3), // sideLength + pJS.particles.shape.polygon.nb_sides, // sideCountNumerator + 1 // sideCountDenominator + ); + break; + + case 'star': + pJS.fn.vendors.drawShape( + pJS.canvas.ctx, + p.x - radius*2 / (pJS.particles.shape.polygon.nb_sides/4), // startX + p.y - radius / (2*2.66/3.5), // startY + radius*2*2.66 / (pJS.particles.shape.polygon.nb_sides/3), // sideLength + pJS.particles.shape.polygon.nb_sides, // sideCountNumerator + 2 // sideCountDenominator + ); + break; + + case 'image': + + function draw(){ + pJS.canvas.ctx.drawImage( + img_obj, + p.x-radius, + p.y-radius, + radius*2, + radius*2 / p.img.ratio + ); + } + + if(pJS.tmp.img_type == 'svg'){ + var img_obj = p.img.obj; + }else{ + var img_obj = pJS.tmp.img_obj; + } + + if(img_obj){ + draw(); + } + + break; + + } + + pJS.canvas.ctx.closePath(); + + if(pJS.particles.shape.stroke.width > 0){ + pJS.canvas.ctx.strokeStyle = pJS.particles.shape.stroke.color; + pJS.canvas.ctx.lineWidth = pJS.particles.shape.stroke.width; + pJS.canvas.ctx.stroke(); + } + + pJS.canvas.ctx.fill(); + + }; + + + pJS.fn.particlesCreate = function(){ + for(var i = 0; i < pJS.particles.number.value; i++) { + pJS.particles.array.push(new pJS.fn.particle(pJS.particles.color, pJS.particles.opacity.value)); + } + }; + + pJS.fn.particlesUpdate = function(){ + + for(var i = 0; i < pJS.particles.array.length; i++){ + + /* the particle */ + var p = pJS.particles.array[i]; + + // var d = ( dx = pJS.interactivity.mouse.click_pos_x - p.x ) * dx + ( dy = pJS.interactivity.mouse.click_pos_y - p.y ) * dy; + // var f = -BANG_SIZE / d; + // if ( d < BANG_SIZE ) { + // var t = Math.atan2( dy, dx ); + // p.vx = f * Math.cos(t); + // p.vy = f * Math.sin(t); + // } + + /* move the particle */ + if(pJS.particles.move.enable){ + var ms = pJS.particles.move.speed/2; + p.x += p.vx * ms; + p.y += p.vy * ms; + } + + /* change opacity status */ + if(pJS.particles.opacity.anim.enable) { + if(p.opacity_status == true) { + if(p.opacity >= pJS.particles.opacity.value) p.opacity_status = false; + p.opacity += p.vo; + }else { + if(p.opacity <= pJS.particles.opacity.anim.opacity_min) p.opacity_status = true; + p.opacity -= p.vo; + } + if(p.opacity < 0) p.opacity = 0; + } + + /* change size */ + if(pJS.particles.size.anim.enable){ + if(p.size_status == true){ + if(p.radius >= pJS.particles.size.value) p.size_status = false; + p.radius += p.vs; + }else{ + if(p.radius <= pJS.particles.size.anim.size_min) p.size_status = true; + p.radius -= p.vs; + } + if(p.radius < 0) p.radius = 0; + } + + /* change particle position if it is out of canvas */ + if(pJS.particles.move.out_mode == 'bounce'){ + var new_pos = { + x_left: p.radius, + x_right: pJS.canvas.w, + y_top: p.radius, + y_bottom: pJS.canvas.h + } + }else{ + var new_pos = { + x_left: -p.radius, + x_right: pJS.canvas.w + p.radius, + y_top: -p.radius, + y_bottom: pJS.canvas.h + p.radius + } + } + + if(p.x - p.radius > pJS.canvas.w){ + p.x = new_pos.x_left; + p.y = Math.random() * pJS.canvas.h; + } + else if(p.x + p.radius < 0){ + p.x = new_pos.x_right; + p.y = Math.random() * pJS.canvas.h; + } + if(p.y - p.radius > pJS.canvas.h){ + p.y = new_pos.y_top; + p.x = Math.random() * pJS.canvas.w; + } + else if(p.y + p.radius < 0){ + p.y = new_pos.y_bottom; + p.x = Math.random() * pJS.canvas.w; + } + + /* out of canvas modes */ + switch(pJS.particles.move.out_mode){ + case 'bounce': + if (p.x + p.radius > pJS.canvas.w) p.vx = -p.vx; + else if (p.x - p.radius < 0) p.vx = -p.vx; + if (p.y + p.radius > pJS.canvas.h) p.vy = -p.vy; + else if (p.y - p.radius < 0) p.vy = -p.vy; + break; + } + + /* events */ + if(isInArray('grab', pJS.interactivity.events.onhover.mode)){ + pJS.fn.modes.grabParticle(p); + } + + if(isInArray('bubble', pJS.interactivity.events.onhover.mode) || isInArray('bubble', pJS.interactivity.events.onclick.mode)){ + pJS.fn.modes.bubbleParticle(p); + } + + if(isInArray('repulse', pJS.interactivity.events.onhover.mode) || isInArray('repulse', pJS.interactivity.events.onclick.mode)){ + pJS.fn.modes.repulseParticle(p); + } + + /* interaction auto between particles */ + if(pJS.particles.line_linked.enable || pJS.particles.move.attract.enable){ + for(var j = i + 1; j < pJS.particles.array.length; j++){ + var p2 = pJS.particles.array[j]; + + /* link particles */ + if(pJS.particles.line_linked.enable){ + pJS.fn.interact.linkParticles(p,p2); + } + + /* attract particles */ + if(pJS.particles.move.attract.enable){ + pJS.fn.interact.attractParticles(p,p2); + } + + /* bounce particles */ + if(pJS.particles.move.bounce){ + pJS.fn.interact.bounceParticles(p,p2); + } + + } + } + + + } + + }; + + pJS.fn.particlesDraw = function(){ + + /* clear canvas */ + pJS.canvas.ctx.clearRect(0, 0, pJS.canvas.w, pJS.canvas.h); + + /* update each particles param */ + pJS.fn.particlesUpdate(); + + /* draw each particle */ + for(var i = 0; i < pJS.particles.array.length; i++){ + var p = pJS.particles.array[i]; + p.draw(); + } + + }; + + pJS.fn.particlesEmpty = function(){ + pJS.particles.array = []; + }; + + pJS.fn.particlesRefresh = function(){ + + /* init all */ + cancelRequestAnimFrame(pJS.fn.checkAnimFrame); + cancelRequestAnimFrame(pJS.fn.drawAnimFrame); + pJS.tmp.source_svg = undefined; + pJS.tmp.img_obj = undefined; + pJS.tmp.count_svg = 0; + pJS.fn.particlesEmpty(); + pJS.fn.canvasClear(); + + /* restart */ + pJS.fn.vendors.start(); + + }; + + + /* ---------- pJS functions - particles interaction ------------ */ + + pJS.fn.interact.linkParticles = function(p1, p2){ + + var dx = p1.x - p2.x, + dy = p1.y - p2.y, + dist = Math.sqrt(dx*dx + dy*dy); + + /* draw a line between p1 and p2 if the distance between them is under the config distance */ + if(dist <= pJS.particles.line_linked.distance){ + + var opacity_line = pJS.particles.line_linked.opacity - (dist / (1/pJS.particles.line_linked.opacity)) / pJS.particles.line_linked.distance; + + if(opacity_line > 0){ + + /* style */ + var color_line = pJS.particles.line_linked.color_rgb_line; + pJS.canvas.ctx.strokeStyle = 'rgba('+color_line.r+','+color_line.g+','+color_line.b+','+opacity_line+')'; + pJS.canvas.ctx.lineWidth = pJS.particles.line_linked.width; + //pJS.canvas.ctx.lineCap = 'round'; /* performance issue */ + + /* path */ + pJS.canvas.ctx.beginPath(); + pJS.canvas.ctx.moveTo(p1.x, p1.y); + pJS.canvas.ctx.lineTo(p2.x, p2.y); + pJS.canvas.ctx.stroke(); + pJS.canvas.ctx.closePath(); + + } + + } + + }; + + + pJS.fn.interact.attractParticles = function(p1, p2){ + + /* condensed particles */ + var dx = p1.x - p2.x, + dy = p1.y - p2.y, + dist = Math.sqrt(dx*dx + dy*dy); + + if(dist <= pJS.particles.line_linked.distance){ + + var ax = dx/(pJS.particles.move.attract.rotateX*1000), + ay = dy/(pJS.particles.move.attract.rotateY*1000); + + p1.vx -= ax; + p1.vy -= ay; + + p2.vx += ax; + p2.vy += ay; + + } + + + } + + + pJS.fn.interact.bounceParticles = function(p1, p2){ + + var dx = p1.x - p2.x, + dy = p1.y - p2.y, + dist = Math.sqrt(dx*dx + dy*dy), + dist_p = p1.radius+p2.radius; + + if(dist <= dist_p){ + p1.vx = -p1.vx; + p1.vy = -p1.vy; + + p2.vx = -p2.vx; + p2.vy = -p2.vy; + } + + } + + + /* ---------- pJS functions - modes events ------------ */ + + pJS.fn.modes.pushParticles = function(nb, pos){ + + pJS.tmp.pushing = true; + + for(var i = 0; i < nb; i++){ + pJS.particles.array.push( + new pJS.fn.particle( + pJS.particles.color, + pJS.particles.opacity.value, + { + 'x': pos ? pos.pos_x : Math.random() * pJS.canvas.w, + 'y': pos ? pos.pos_y : Math.random() * pJS.canvas.h + } + ) + ) + if(i == nb-1){ + if(!pJS.particles.move.enable){ + pJS.fn.particlesDraw(); + } + pJS.tmp.pushing = false; + } + } + + }; + + + pJS.fn.modes.removeParticles = function(nb){ + + pJS.particles.array.splice(0, nb); + if(!pJS.particles.move.enable){ + pJS.fn.particlesDraw(); + } + + }; + + + pJS.fn.modes.bubbleParticle = function(p){ + + /* on hover event */ + if(pJS.interactivity.events.onhover.enable && isInArray('bubble', pJS.interactivity.events.onhover.mode)){ + + var dx_mouse = p.x - pJS.interactivity.mouse.pos_x, + dy_mouse = p.y - pJS.interactivity.mouse.pos_y, + dist_mouse = Math.sqrt(dx_mouse*dx_mouse + dy_mouse*dy_mouse), + ratio = 1 - dist_mouse / pJS.interactivity.modes.bubble.distance; + + function init(){ + p.opacity_bubble = p.opacity; + p.radius_bubble = p.radius; + } + + /* mousemove - check ratio */ + if(dist_mouse <= pJS.interactivity.modes.bubble.distance){ + + if(ratio >= 0 && pJS.interactivity.status == 'mousemove'){ + + /* size */ + if(pJS.interactivity.modes.bubble.size != pJS.particles.size.value){ + + if(pJS.interactivity.modes.bubble.size > pJS.particles.size.value){ + var size = p.radius + (pJS.interactivity.modes.bubble.size*ratio); + if(size >= 0){ + p.radius_bubble = size; + } + }else{ + var dif = p.radius - pJS.interactivity.modes.bubble.size, + size = p.radius - (dif*ratio); + if(size > 0){ + p.radius_bubble = size; + }else{ + p.radius_bubble = 0; + } + } + + } + + /* opacity */ + if(pJS.interactivity.modes.bubble.opacity != pJS.particles.opacity.value){ + + if(pJS.interactivity.modes.bubble.opacity > pJS.particles.opacity.value){ + var opacity = pJS.interactivity.modes.bubble.opacity*ratio; + if(opacity > p.opacity && opacity <= pJS.interactivity.modes.bubble.opacity){ + p.opacity_bubble = opacity; + } + }else{ + var opacity = p.opacity - (pJS.particles.opacity.value-pJS.interactivity.modes.bubble.opacity)*ratio; + if(opacity < p.opacity && opacity >= pJS.interactivity.modes.bubble.opacity){ + p.opacity_bubble = opacity; + } + } + + } + + } + + }else{ + init(); + } + + + /* mouseleave */ + if(pJS.interactivity.status == 'mouseleave'){ + init(); + } + + } + + /* on click event */ + else if(pJS.interactivity.events.onclick.enable && isInArray('bubble', pJS.interactivity.events.onclick.mode)){ + + + if(pJS.tmp.bubble_clicking){ + var dx_mouse = p.x - pJS.interactivity.mouse.click_pos_x, + dy_mouse = p.y - pJS.interactivity.mouse.click_pos_y, + dist_mouse = Math.sqrt(dx_mouse*dx_mouse + dy_mouse*dy_mouse), + time_spent = (new Date().getTime() - pJS.interactivity.mouse.click_time)/1000; + + if(time_spent > pJS.interactivity.modes.bubble.duration){ + pJS.tmp.bubble_duration_end = true; + } + + if(time_spent > pJS.interactivity.modes.bubble.duration*2){ + pJS.tmp.bubble_clicking = false; + pJS.tmp.bubble_duration_end = false; + } + } + + + function process(bubble_param, particles_param, p_obj_bubble, p_obj, id){ + + if(bubble_param != particles_param){ + + if(!pJS.tmp.bubble_duration_end){ + if(dist_mouse <= pJS.interactivity.modes.bubble.distance){ + if(p_obj_bubble != undefined) var obj = p_obj_bubble; + else var obj = p_obj; + if(obj != bubble_param){ + var value = p_obj - (time_spent * (p_obj - bubble_param) / pJS.interactivity.modes.bubble.duration); + if(id == 'size') p.radius_bubble = value; + if(id == 'opacity') p.opacity_bubble = value; + } + }else{ + if(id == 'size') p.radius_bubble = undefined; + if(id == 'opacity') p.opacity_bubble = undefined; + } + }else{ + if(p_obj_bubble != undefined){ + var value_tmp = p_obj - (time_spent * (p_obj - bubble_param) / pJS.interactivity.modes.bubble.duration), + dif = bubble_param - value_tmp; + value = bubble_param + dif; + if(id == 'size') p.radius_bubble = value; + if(id == 'opacity') p.opacity_bubble = value; + } + } + + } + + } + + if(pJS.tmp.bubble_clicking){ + /* size */ + process(pJS.interactivity.modes.bubble.size, pJS.particles.size.value, p.radius_bubble, p.radius, 'size'); + /* opacity */ + process(pJS.interactivity.modes.bubble.opacity, pJS.particles.opacity.value, p.opacity_bubble, p.opacity, 'opacity'); + } + + } + + }; + + + pJS.fn.modes.repulseParticle = function(p){ + + if(pJS.interactivity.events.onhover.enable && isInArray('repulse', pJS.interactivity.events.onhover.mode) && pJS.interactivity.status == 'mousemove') { + + var dx_mouse = p.x - pJS.interactivity.mouse.pos_x, + dy_mouse = p.y - pJS.interactivity.mouse.pos_y, + dist_mouse = Math.sqrt(dx_mouse*dx_mouse + dy_mouse*dy_mouse); + + var normVec = {x: dx_mouse/dist_mouse, y: dy_mouse/dist_mouse}, + repulseRadius = pJS.interactivity.modes.repulse.distance, + velocity = 100, + repulseFactor = clamp((1/repulseRadius)*(-1*Math.pow(dist_mouse/repulseRadius,2)+1)*repulseRadius*velocity, 0, 50); + + var pos = { + x: p.x + normVec.x * repulseFactor, + y: p.y + normVec.y * repulseFactor + } + + if(pJS.particles.move.out_mode == 'bounce'){ + if(pos.x - p.radius > 0 && pos.x + p.radius < pJS.canvas.w) p.x = pos.x; + if(pos.y - p.radius > 0 && pos.y + p.radius < pJS.canvas.h) p.y = pos.y; + }else{ + p.x = pos.x; + p.y = pos.y; + } + + } + + + else if(pJS.interactivity.events.onclick.enable && isInArray('repulse', pJS.interactivity.events.onclick.mode)) { + + if(!pJS.tmp.repulse_finish){ + pJS.tmp.repulse_count++; + if(pJS.tmp.repulse_count == pJS.particles.array.length){ + pJS.tmp.repulse_finish = true; + } + } + + if(pJS.tmp.repulse_clicking){ + + var repulseRadius = Math.pow(pJS.interactivity.modes.repulse.distance/6, 3); + + var dx = pJS.interactivity.mouse.click_pos_x - p.x, + dy = pJS.interactivity.mouse.click_pos_y - p.y, + d = dx*dx + dy*dy; + + var force = -repulseRadius / d * 1; + + function process(){ + + var f = Math.atan2(dy,dx); + p.vx = force * Math.cos(f); + p.vy = force * Math.sin(f); + + if(pJS.particles.move.out_mode == 'bounce'){ + var pos = { + x: p.x + p.vx, + y: p.y + p.vy + } + if (pos.x + p.radius > pJS.canvas.w) p.vx = -p.vx; + else if (pos.x - p.radius < 0) p.vx = -p.vx; + if (pos.y + p.radius > pJS.canvas.h) p.vy = -p.vy; + else if (pos.y - p.radius < 0) p.vy = -p.vy; + } + + } + + // default + if(d <= repulseRadius){ + process(); + } + + // bang - slow motion mode + // if(!pJS.tmp.repulse_finish){ + // if(d <= repulseRadius){ + // process(); + // } + // }else{ + // process(); + // } + + + }else{ + + if(pJS.tmp.repulse_clicking == false){ + + p.vx = p.vx_i; + p.vy = p.vy_i; + + } + + } + + } + + } + + + pJS.fn.modes.grabParticle = function(p){ + + if(pJS.interactivity.events.onhover.enable && pJS.interactivity.status == 'mousemove'){ + + var dx_mouse = p.x - pJS.interactivity.mouse.pos_x, + dy_mouse = p.y - pJS.interactivity.mouse.pos_y, + dist_mouse = Math.sqrt(dx_mouse*dx_mouse + dy_mouse*dy_mouse); + + /* draw a line between the cursor and the particle if the distance between them is under the config distance */ + if(dist_mouse <= pJS.interactivity.modes.grab.distance){ + + var opacity_line = pJS.interactivity.modes.grab.line_linked.opacity - (dist_mouse / (1/pJS.interactivity.modes.grab.line_linked.opacity)) / pJS.interactivity.modes.grab.distance; + + if(opacity_line > 0){ + + /* style */ + var color_line = pJS.particles.line_linked.color_rgb_line; + pJS.canvas.ctx.strokeStyle = 'rgba('+color_line.r+','+color_line.g+','+color_line.b+','+opacity_line+')'; + pJS.canvas.ctx.lineWidth = pJS.particles.line_linked.width; + //pJS.canvas.ctx.lineCap = 'round'; /* performance issue */ + + /* path */ + pJS.canvas.ctx.beginPath(); + pJS.canvas.ctx.moveTo(p.x, p.y); + pJS.canvas.ctx.lineTo(pJS.interactivity.mouse.pos_x, pJS.interactivity.mouse.pos_y); + pJS.canvas.ctx.stroke(); + pJS.canvas.ctx.closePath(); + + } + + } + + } + + }; + + + + /* ---------- pJS functions - vendors ------------ */ + + pJS.fn.vendors.eventsListeners = function(){ + + /* events target element */ + if(pJS.interactivity.detect_on == 'window'){ + pJS.interactivity.el = window; + }else{ + pJS.interactivity.el = pJS.canvas.el; + } + + + /* detect mouse pos - on hover / click event */ + if(pJS.interactivity.events.onhover.enable || pJS.interactivity.events.onclick.enable){ + + /* el on mousemove */ + pJS.interactivity.el.addEventListener('mousemove', function(e){ + + if(pJS.interactivity.el == window){ + var pos_x = e.clientX, + pos_y = e.clientY; + } + else{ + var pos_x = e.offsetX || e.clientX, + pos_y = e.offsetY || e.clientY; + } + + pJS.interactivity.mouse.pos_x = pos_x; + pJS.interactivity.mouse.pos_y = pos_y; + + if(pJS.tmp.retina){ + pJS.interactivity.mouse.pos_x *= pJS.canvas.pxratio; + pJS.interactivity.mouse.pos_y *= pJS.canvas.pxratio; + } + + pJS.interactivity.status = 'mousemove'; + + }); + + /* el on onmouseleave */ + pJS.interactivity.el.addEventListener('mouseleave', function(e){ + + pJS.interactivity.mouse.pos_x = null; + pJS.interactivity.mouse.pos_y = null; + pJS.interactivity.status = 'mouseleave'; + + }); + + } + + /* on click event */ + if(pJS.interactivity.events.onclick.enable){ + + pJS.interactivity.el.addEventListener('click', function(){ + + pJS.interactivity.mouse.click_pos_x = pJS.interactivity.mouse.pos_x; + pJS.interactivity.mouse.click_pos_y = pJS.interactivity.mouse.pos_y; + pJS.interactivity.mouse.click_time = new Date().getTime(); + + if(pJS.interactivity.events.onclick.enable){ + + switch(pJS.interactivity.events.onclick.mode){ + + case 'push': + if(pJS.particles.move.enable){ + pJS.fn.modes.pushParticles(pJS.interactivity.modes.push.particles_nb, pJS.interactivity.mouse); + }else{ + if(pJS.interactivity.modes.push.particles_nb == 1){ + pJS.fn.modes.pushParticles(pJS.interactivity.modes.push.particles_nb, pJS.interactivity.mouse); + } + else if(pJS.interactivity.modes.push.particles_nb > 1){ + pJS.fn.modes.pushParticles(pJS.interactivity.modes.push.particles_nb); + } + } + break; + + case 'remove': + pJS.fn.modes.removeParticles(pJS.interactivity.modes.remove.particles_nb); + break; + + case 'bubble': + pJS.tmp.bubble_clicking = true; + break; + + case 'repulse': + pJS.tmp.repulse_clicking = true; + pJS.tmp.repulse_count = 0; + pJS.tmp.repulse_finish = false; + setTimeout(function(){ + pJS.tmp.repulse_clicking = false; + }, pJS.interactivity.modes.repulse.duration*1000) + break; + + } + + } + + }); + + } + + + }; + + pJS.fn.vendors.densityAutoParticles = function(){ + + if(pJS.particles.number.density.enable){ + + /* calc area */ + var area = pJS.canvas.el.width * pJS.canvas.el.height / 1000; + if(pJS.tmp.retina){ + area = area/(pJS.canvas.pxratio*2); + } + + /* calc number of particles based on density area */ + var nb_particles = area * pJS.particles.number.value / pJS.particles.number.density.value_area; + + /* add or remove X particles */ + var missing_particles = pJS.particles.array.length - nb_particles; + if(missing_particles < 0) pJS.fn.modes.pushParticles(Math.abs(missing_particles)); + else pJS.fn.modes.removeParticles(missing_particles); + + } + + }; + + + pJS.fn.vendors.checkOverlap = function(p1, position){ + for(var i = 0; i < pJS.particles.array.length; i++){ + var p2 = pJS.particles.array[i]; + + var dx = p1.x - p2.x, + dy = p1.y - p2.y, + dist = Math.sqrt(dx*dx + dy*dy); + + if(dist <= p1.radius + p2.radius){ + p1.x = position ? position.x : Math.random() * pJS.canvas.w; + p1.y = position ? position.y : Math.random() * pJS.canvas.h; + pJS.fn.vendors.checkOverlap(p1); + } + } + }; + + + pJS.fn.vendors.createSvgImg = function(p){ + + /* set color to svg element */ + var svgXml = pJS.tmp.source_svg, + rgbHex = /#([0-9A-F]{3,6})/gi, + coloredSvgXml = svgXml.replace(rgbHex, function (m, r, g, b) { + if(p.color.rgb){ + var color_value = 'rgba('+p.color.rgb.r+','+p.color.rgb.g+','+p.color.rgb.b+','+p.opacity+')'; + }else{ + var color_value = 'hsla('+p.color.hsl.h+','+p.color.hsl.s+'%,'+p.color.hsl.l+'%,'+p.opacity+')'; + } + return color_value; + }); + + /* prepare to create img with colored svg */ + var svg = new Blob([coloredSvgXml], {type: 'image/svg+xml;charset=utf-8'}), + DOMURL = window.URL || window.webkitURL || window, + url = DOMURL.createObjectURL(svg); + + /* create particle img obj */ + var img = new Image(); + img.addEventListener('load', function(){ + p.img.obj = img; + p.img.loaded = true; + DOMURL.revokeObjectURL(url); + pJS.tmp.count_svg++; + }); + img.src = url; + + }; + + + pJS.fn.vendors.destroypJS = function(){ + cancelAnimationFrame(pJS.fn.drawAnimFrame); + canvas_el.remove(); + pJSDom = null; + }; + + + pJS.fn.vendors.drawShape = function(c, startX, startY, sideLength, sideCountNumerator, sideCountDenominator){ + + // By Programming Thomas - https://programmingthomas.wordpress.com/2013/04/03/n-sided-shapes/ + var sideCount = sideCountNumerator * sideCountDenominator; + var decimalSides = sideCountNumerator / sideCountDenominator; + var interiorAngleDegrees = (180 * (decimalSides - 2)) / decimalSides; + var interiorAngle = Math.PI - Math.PI * interiorAngleDegrees / 180; // convert to radians + c.save(); + c.beginPath(); + c.translate(startX, startY); + c.moveTo(0,0); + for (var i = 0; i < sideCount; i++) { + c.lineTo(sideLength,0); + c.translate(sideLength,0); + c.rotate(interiorAngle); + } + //c.stroke(); + c.fill(); + c.restore(); + + }; + + pJS.fn.vendors.exportImg = function(){ + window.open(pJS.canvas.el.toDataURL('image/png'), '_blank'); + }; + + + pJS.fn.vendors.loadImg = function(type){ + + pJS.tmp.img_error = undefined; + + if(pJS.particles.shape.image.src != ''){ + + if(type == 'svg'){ + + var xhr = new XMLHttpRequest(); + xhr.open('GET', pJS.particles.shape.image.src); + xhr.onreadystatechange = function (data) { + if(xhr.readyState == 4){ + if(xhr.status == 200){ + pJS.tmp.source_svg = data.currentTarget.response; + pJS.fn.vendors.checkBeforeDraw(); + }else{ + console.log('Error pJS - Image not found'); + pJS.tmp.img_error = true; + } + } + } + xhr.send(); + + }else{ + + var img = new Image(); + img.addEventListener('load', function(){ + pJS.tmp.img_obj = img; + pJS.fn.vendors.checkBeforeDraw(); + }); + img.src = pJS.particles.shape.image.src; + + } + + }else{ + console.log('Error pJS - No image.src'); + pJS.tmp.img_error = true; + } + + }; + + + pJS.fn.vendors.draw = function(){ + + if(pJS.particles.shape.type == 'image'){ + + if(pJS.tmp.img_type == 'svg'){ + + if(pJS.tmp.count_svg >= pJS.particles.number.value){ + pJS.fn.particlesDraw(); + if(!pJS.particles.move.enable) cancelRequestAnimFrame(pJS.fn.drawAnimFrame); + else pJS.fn.drawAnimFrame = requestAnimFrame(pJS.fn.vendors.draw); + }else{ + //console.log('still loading...'); + if(!pJS.tmp.img_error) pJS.fn.drawAnimFrame = requestAnimFrame(pJS.fn.vendors.draw); + } + + }else{ + + if(pJS.tmp.img_obj != undefined){ + pJS.fn.particlesDraw(); + if(!pJS.particles.move.enable) cancelRequestAnimFrame(pJS.fn.drawAnimFrame); + else pJS.fn.drawAnimFrame = requestAnimFrame(pJS.fn.vendors.draw); + }else{ + if(!pJS.tmp.img_error) pJS.fn.drawAnimFrame = requestAnimFrame(pJS.fn.vendors.draw); + } + + } + + }else{ + pJS.fn.particlesDraw(); + if(!pJS.particles.move.enable) cancelRequestAnimFrame(pJS.fn.drawAnimFrame); + else pJS.fn.drawAnimFrame = requestAnimFrame(pJS.fn.vendors.draw); + } + + }; + + + pJS.fn.vendors.checkBeforeDraw = function(){ + + // if shape is image + if(pJS.particles.shape.type == 'image'){ + + if(pJS.tmp.img_type == 'svg' && pJS.tmp.source_svg == undefined){ + pJS.tmp.checkAnimFrame = requestAnimFrame(check); + }else{ + //console.log('images loaded! cancel check'); + cancelRequestAnimFrame(pJS.tmp.checkAnimFrame); + if(!pJS.tmp.img_error){ + pJS.fn.vendors.init(); + pJS.fn.vendors.draw(); + } + + } + + }else{ + pJS.fn.vendors.init(); + pJS.fn.vendors.draw(); + } + + }; + + + pJS.fn.vendors.init = function(){ + + /* init canvas + particles */ + pJS.fn.retinaInit(); + pJS.fn.canvasInit(); + pJS.fn.canvasSize(); + pJS.fn.canvasPaint(); + pJS.fn.particlesCreate(); + pJS.fn.vendors.densityAutoParticles(); + + /* particles.line_linked - convert hex colors to rgb */ + pJS.particles.line_linked.color_rgb_line = hexToRgb(pJS.particles.line_linked.color); + + }; + + + pJS.fn.vendors.start = function(){ + + if(isInArray('image', pJS.particles.shape.type)){ + pJS.tmp.img_type = pJS.particles.shape.image.src.substr(pJS.particles.shape.image.src.length - 3); + pJS.fn.vendors.loadImg(pJS.tmp.img_type); + }else{ + pJS.fn.vendors.checkBeforeDraw(); + } + + }; + + + + + /* ---------- pJS - start ------------ */ + + + pJS.fn.vendors.eventsListeners(); + + pJS.fn.vendors.start(); + + + +}; + +/* ---------- global functions - vendors ------------ */ + +Object.deepExtend = function(destination, source) { + for (var property in source) { + if (source[property] && source[property].constructor && + source[property].constructor === Object) { + destination[property] = destination[property] || {}; + arguments.callee(destination[property], source[property]); + } else { + destination[property] = source[property]; + } + } + return destination; +}; + +window.requestAnimFrame = (function(){ + return window.requestAnimationFrame || + window.webkitRequestAnimationFrame || + window.mozRequestAnimationFrame || + window.oRequestAnimationFrame || + window.msRequestAnimationFrame || + function(callback){ + window.setTimeout(callback, 1000 / 60); + }; +})(); + +window.cancelRequestAnimFrame = ( function() { + return window.cancelAnimationFrame || + window.webkitCancelRequestAnimationFrame || + window.mozCancelRequestAnimationFrame || + window.oCancelRequestAnimationFrame || + window.msCancelRequestAnimationFrame || + clearTimeout +} )(); + +function hexToRgb(hex){ + // By Tim Down - http://stackoverflow.com/a/5624139/3493650 + // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") + var shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; + hex = hex.replace(shorthandRegex, function(m, r, g, b) { + return r + r + g + g + b + b; + }); + var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : null; +}; + +function clamp(number, min, max) { + return Math.min(Math.max(number, min), max); +}; + +function isInArray(value, array) { + return array.indexOf(value) > -1; +} + + +/* ---------- particles.js functions - start ------------ */ + +window.pJSDom = []; + +window.particlesJS = function(tag_id, params){ + + //console.log(params); + + /* no string id? so it's object params, and set the id with default id */ + if(typeof(tag_id) != 'string'){ + params = tag_id; + tag_id = 'particles-js'; + } + + /* no id? set the id to default id */ + if(!tag_id){ + tag_id = 'particles-js'; + } + + /* pJS elements */ + var pJS_tag = document.getElementById(tag_id), + pJS_canvas_class = 'particles-js-canvas-el', + exist_canvas = pJS_tag.getElementsByClassName(pJS_canvas_class); + + /* remove canvas if exists into the pJS target tag */ + if(exist_canvas.length){ + while(exist_canvas.length > 0){ + pJS_tag.removeChild(exist_canvas[0]); + } + } + + /* create canvas element */ + var canvas_el = document.createElement('canvas'); + canvas_el.className = pJS_canvas_class; + + /* set size canvas */ + canvas_el.style.width = "100%"; + canvas_el.style.height = "100%"; + + /* append canvas */ + var canvas = document.getElementById(tag_id).appendChild(canvas_el); + + /* launch particle.js */ + if(canvas != null){ + pJSDom.push(new pJS(tag_id, params)); + } + +}; + +window.particlesJS.load = function(tag_id, path_config_json, callback){ + + /* load json config */ + var xhr = new XMLHttpRequest(); + xhr.open('GET', path_config_json); + xhr.onreadystatechange = function (data) { + if(xhr.readyState == 4){ + if(xhr.status == 200){ + var params = JSON.parse(data.currentTarget.response); + window.particlesJS(tag_id, params); + if(callback) callback(); + }else{ + console.log('Error pJS - XMLHttpRequest status: '+xhr.status); + console.log('Error pJS - File config not found'); + } + } + }; + xhr.send(); + +}; \ No newline at end of file diff --git a/knockoutwhistweb/test/controllers/HomeControllerSpec.scala b/knockoutwhistweb/test/controllers/HomeControllerSpec.scala index cc4ca72..db9c4b9 100644 --- a/knockoutwhistweb/test/controllers/HomeControllerSpec.scala +++ b/knockoutwhistweb/test/controllers/HomeControllerSpec.scala @@ -13,33 +13,33 @@ import play.api.test.Helpers.* */ class HomeControllerSpec extends PlaySpec with GuiceOneAppPerTest with Injecting { - "HomeController GET" should { - - "render the index page from a new instance of controller" in { - val controller = new HomeController(stubControllerComponents()) - val home = controller.index().apply(FakeRequest(GET, "/")) - - status(home) mustBe OK - contentType(home) mustBe Some("text/html") - contentAsString(home) must include ("Welcome to Play") - } - - "render the index page from the application" in { - val controller = inject[HomeController] - val home = controller.index().apply(FakeRequest(GET, "/")) - - status(home) mustBe OK - contentType(home) mustBe Some("text/html") - contentAsString(home) must include ("Welcome to Play") - } - - "render the index page from the router" in { - val request = FakeRequest(GET, "/") - val home = route(app, request).get - - status(home) mustBe OK - contentType(home) mustBe Some("text/html") - contentAsString(home) must include ("Welcome to Play") - } - } +// "HomeController GET" should { +// +// "render the index page from a new instance of controller" in { +// val controller = new HomeController(stubControllerComponents()) +// val home = controller.index().apply(FakeRequest(GET, "/")) +// +// status(home) mustBe OK +// contentType(home) mustBe Some("text/html") +// contentAsString(home) must include ("Welcome to Play") +// } +// +// "render the index page from the application" in { +// val controller = inject[HomeController] +// val home = controller.index().apply(FakeRequest(GET, "/")) +// +// status(home) mustBe OK +// contentType(home) mustBe Some("text/html") +// contentAsString(home) must include ("Welcome to Play") +// } +// +// "render the index page from the router" in { +// val request = FakeRequest(GET, "/") +// val home = route(app, request).get +// +// status(home) mustBe OK +// contentType(home) mustBe Some("text/html") +// contentAsString(home) must include ("Welcome to Play") +// } +// } }