Compare commits
127 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 832afc1128 | |||
| 4f3897bfaf | |||
| d4228c60f3 | |||
| 7b9529bddb | |||
| ba91f1ec50 | |||
| debc9495ae | |||
| f4763e711c | |||
| 788fc2dfe1 | |||
| 07ebca0834 | |||
| 52e223f2dd | |||
| 60aee72af3 | |||
| 3e524968f3 | |||
| af22f0908a | |||
| 1ae0e8b169 | |||
| 64ac0bbebb | |||
| 2328a7251a | |||
| b56530bf92 | |||
| 011f285bf3 | |||
| 2bf6cbe968 | |||
| 1075422a1d | |||
| 5a682640c6 | |||
| 1a6f07e84f | |||
| 6a3ce486df | |||
| 32bb31ecf1 | |||
| 6b01a0272e | |||
| 0555154ea6 | |||
| b6cad231c3 | |||
| c09181f380 | |||
| 8ba28261f8 | |||
| 93fe1d64fd | |||
| dab366496e | |||
| 45507df720 | |||
| e12115792d | |||
| cc50d990eb | |||
| e4f578b3da | |||
| a67cec1bd5 | |||
| 6e37fcfbed | |||
| 1ed1f15d36 | |||
| 994e0d2d5b | |||
| 39683946a7 | |||
| 29bcaace1e | |||
| 8824f9215b | |||
| d117fb2d21 | |||
| a72421defe | |||
| 2fbb7f69a8 | |||
| 20c98e77a4 | |||
| ea9effe3ba | |||
| 902c3073ec | |||
| 2db1129489 | |||
| 823b035d70 | |||
| 18187b69b0 | |||
| a6aae78396 | |||
| 79b9b7b9eb | |||
| f9e64cf234 | |||
| 477a10238a | |||
| dc544e61e1 | |||
| 992c326c19 | |||
| 6cd5bd48f5 | |||
| ccdd995e18 | |||
| bb1cf4b3c2 | |||
| e6c41e34af | |||
| cbd7635759 | |||
| d8d6533dae | |||
| afa35ee79e | |||
| aa8a069afb | |||
| f66f8e6d52 | |||
| 709868ebf4 | |||
| 04e21c35c7 | |||
| ee9affabaa | |||
| 86c46dc430 | |||
| d101b6c9a7 | |||
| 8b8d3ea2a0 | |||
| d4decdf21f | |||
| b36f1d4bc0 | |||
| bcf34d68e4 | |||
| 40022df835 | |||
| f480c808f3 | |||
| 9f9dbf7506 | |||
| 2d3c6943e0 | |||
| b87b324ddd | |||
| 516f2f2f8c | |||
| 51af77d61d | |||
| 304557163b | |||
| aea4ac2518 | |||
| 19bd063c24 | |||
| 8f5373b2a7 | |||
| 964268c311 | |||
| 82b74dcbe2 | |||
| c5e4c6d27a | |||
| 2dfa43f157 | |||
| ca431cfeea | |||
| 36ac8fb474 | |||
| 6abd2d6c81 | |||
| 45f416433b | |||
| 32f6976239 | |||
| c5cb83abca | |||
| de6644993e | |||
| 960999a803 | |||
| 4520129f9f | |||
| bdec497bc1 | |||
| 04bfc22330 | |||
| 4b112dcee9 | |||
| cada410f93 | |||
| 1e9752eeaf | |||
| 68aa23ac24 | |||
| 1ee091c96f | |||
| 850df645bd | |||
| 7d6dc6e484 | |||
| 1938bdd8d2 | |||
| 51ad2ca9c5 | |||
| 7711ae0ebb | |||
| 7d7bd8b55b | |||
| 225c1b515a | |||
| c9f599f878 | |||
| a41604e3b2 | |||
| e3d9337438 | |||
| 877331e8a4 | |||
| 504e42960c | |||
| 139d85001c | |||
| e2cac27a25 | |||
| 554cb26ad2 | |||
| 3610e50426 | |||
| a6b7e1194c | |||
| 1474a9ac7c | |||
| 20bed545e1 | |||
| e006c29d2b | |||
| 17af4376c0 |
47 changed files with 2516 additions and 1571 deletions
26
.github/workflows/release.yml
vendored
Normal file
26
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
name: Release
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- run: npx changelogithub
|
||||
env:
|
||||
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
|
||||
*.*~
|
||||
*.*swp
|
||||
|
|
|
|||
2
.husky/pre-commit
Normal file
2
.husky/pre-commit
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
echo "pre-commit..."
|
||||
npm run lint
|
||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
lts/*
|
||||
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"editor.formatOnSave": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
}
|
||||
53
README.md
53
README.md
|
|
@ -1,15 +1,39 @@
|
|||
# Play Minesweeper Online
|
||||
# Play Minesweeper Online for Free
|
||||
[](https://app.netlify.com/sites/mnswpr/deploys)
|
||||
|
||||
Play it here: [mnswpr.com](https://mnswpr.com). This is the classic game **Minesweeper** built with vanilla web technologies (i.e., no framework dependency).
|
||||
|
||||
Technology Stack:
|
||||
- HTML, JS, and CSS
|
||||
- Webpack for bundling
|
||||
- Firebase for leader board store
|
||||
- Netlify for hosting
|
||||
Technology Stack: HTML, JS, and CSS; [Google Firebase](https://firebase.google.com) for leader board store; [Netlify](https://netlify.com) for hosting
|
||||
|
||||
## Project motivation
|
||||
## Usage
|
||||
|
||||
The web is a wonderful, free, and open platform to create and distribute value. You can use **mnswpr** in different ways:
|
||||
|
||||
- as a deployed [web app](https://mnswpr.com)
|
||||
- as a [library](https://npmx.dev/package/@ayo-run/mnswpr) with `npm i @ayo-run/mnswpr`
|
||||
- as a `web component` (coming soon).
|
||||
|
||||
## Tooling
|
||||
The project has gone through years of existence. It started from 2019 when tooling was massively different. I have [modernized it](https://elk.zone/social.ayco.io/@ayo/116333804543330938) since and have witnessed how much easier and faster it is to build now - even without web frameworks or LLMs!
|
||||
|
||||
As of now the tooling I use are:
|
||||
- [Vite](https://vite.dev/) for bundling and development server
|
||||
- [Eslint](https://eslint.org) for JS linting & [CSS linting](https://eslint.org/blog/2025/02/eslint-css-support/)
|
||||
- [ESLint Stylistic](https://eslint.style) for JS formatting
|
||||
- [Husky](https://typicode.github.io/husky/) for git hooks
|
||||
- [PNPM](https://pnpm.io/installation) for dependency & workspace management
|
||||
- and a bunch of automation using scripts and Continuous Integration actions
|
||||
|
||||
## Development
|
||||
To start development, you need [`node`](https://nodejs.org/en/download). I highly recommend [`pnpm`](https://pnpm.io/installation) to be used as well. Once you know you have this, you can do the following:
|
||||
1. Install dependencies: `pnpm i`
|
||||
2. Start the dev server: `pnpm run dev`
|
||||
|
||||
## You just want to play?
|
||||
|
||||
*👉 The live site is here: [mnswpr.com](https://mnswpr.com)*
|
||||
|
||||
## Background
|
||||
One day, while working in my home office, I heard loud and fast mouse clicks coming from our bedroom. It's my wife, playing her favorite game (Minesweeper) on a crappy website full of advertisements.
|
||||
|
||||
I can't allow this, it's a security issue. 🤣
|
||||
|
|
@ -28,18 +52,9 @@ Can I make a page with complex interactions (more on this later) without any lib
|
|||
1. Competition motivates users to use your app more ✨
|
||||
1. Hash in bundled filenames help issues in browser caching (when shipping versions fast) ✨
|
||||
|
||||
## Development
|
||||
To start development, you need node v16 (the dev server doesn't work on v18 *yet*). Once you know you have this, you can do the following:
|
||||
1. Install dependencies: `npm i`
|
||||
2. Start the dev server: `npm run dev`
|
||||
|
||||
## Running Locally
|
||||
After running the commands to start development, open in a browser: `http://localhost:4200`
|
||||
|
||||
## Contribution reminders
|
||||
---
|
||||
|
||||
1. There's no hot reloading. You have to reload the app after making a change. OR contribute to enable HMR? ;)
|
||||
2. This is a very messy code; a product of quick hacking and shipping while live users are giving feedback. Let's clean it up!
|
||||
|
||||
## Live Demo
|
||||
*👉 The live site is here: [Minesweeper](https://mnswpr.com)*
|
||||
_Just keep building._<br>
|
||||
_A project by [Ayo](https://ayo.ayco.io)_
|
||||
|
|
|
|||
45
app/index.html
Normal file
45
app/index.html
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
|
||||
<meta name="Description" content="Play Minesweeper online for FREE!" />
|
||||
<title>Play Minesweeper online for FREE!</title>
|
||||
|
||||
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="./main.css" />
|
||||
<link rel="stylesheet" href="../utils/loading/loading.css" />
|
||||
<style>
|
||||
:host, :root{
|
||||
--mnswpr-transition: 10s ease-in-out;
|
||||
}
|
||||
|
||||
nav {
|
||||
a {
|
||||
color: white;
|
||||
text-decoration-color: orange;
|
||||
transition: 500ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="body-wrapper">
|
||||
<nav>
|
||||
<a target="_blank" href="https://npmx.dev/package/@ayo-run/mnswpr">npm</a>
|
||||
·
|
||||
<a target="_blank" href="https://github.com/ayo-run/mnswpr">github</a>
|
||||
</nav>
|
||||
<div id="app">
|
||||
Please use Chrome or Firefox.
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
app/main.css
Normal file
25
app/main.css
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/* helpers */
|
||||
.float-left {
|
||||
float: left;
|
||||
}
|
||||
.float-right {
|
||||
float: right;
|
||||
}
|
||||
.clear-both {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
body {
|
||||
background: black;
|
||||
color: #DDDDDD;
|
||||
font-family: medium-content-sans-serif-font, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
em {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#body-wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
42
app/main.js
Normal file
42
app/main.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import mnswpr from '@ayo-run/mnswpr/mnswpr.js'
|
||||
import '@ayo-run/mnswpr/mnswpr.css'
|
||||
import * as pkg from '@ayo-run/mnswpr/package.json'
|
||||
import { LoadingService } from '../utils/'
|
||||
import { LeaderBoardService } from './modules/leader-board/leader-board.js'
|
||||
|
||||
const leaderBoardService = new LeaderBoardService()
|
||||
const loadingService = new LoadingService()
|
||||
|
||||
const version = import.meta.env.MODE === 'development'
|
||||
? 'dev'
|
||||
: pkg.version
|
||||
|
||||
const initializeGameBoard = async (level) => {
|
||||
const prevousLeaderBoard = document.getElementById('leaderboard')
|
||||
const loadingWrapper = document.createElement('div')
|
||||
loadingWrapper.id = 'loading-wrapper'
|
||||
loadingService.addLoading(loadingWrapper)
|
||||
|
||||
const appElement = document.getElementById('app')
|
||||
if (prevousLeaderBoard){
|
||||
const parent = prevousLeaderBoard.parentNode
|
||||
parent.replaceChild(loadingWrapper, prevousLeaderBoard)
|
||||
}else{
|
||||
appElement.append(loadingWrapper)
|
||||
}
|
||||
const leaderBoardWrapper = await leaderBoardService.update(level.id, `Best Times (${level.name})`)
|
||||
leaderBoardWrapper.id = 'leaderboard'
|
||||
|
||||
appElement.replaceChild(leaderBoardWrapper, loadingWrapper)
|
||||
}
|
||||
|
||||
const sendGameResult = (game) => {
|
||||
leaderBoardService.send(game, 'time')
|
||||
}
|
||||
|
||||
const game = new mnswpr('app', version, {
|
||||
levelChanged: (level) => initializeGameBoard(level),
|
||||
gameDone: (game) => sendGameResult(game)
|
||||
})
|
||||
|
||||
game.initialize()
|
||||
176
app/modules/leader-board/leader-board.js
Normal file
176
app/modules/leader-board/leader-board.js
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { TimerService } from '../../../utils/timer/timer'
|
||||
import { LoggerService } from '../../../utils/logger/logger'
|
||||
import { UserService } from '../user/user'
|
||||
|
||||
import { initializeApp } from 'firebase/app'
|
||||
import {
|
||||
getFirestore, doc, getDocs, getDoc, setDoc, collection, query, orderBy, limit
|
||||
} from 'firebase/firestore/lite'
|
||||
|
||||
|
||||
export class LeaderBoardService {
|
||||
|
||||
timerService = new TimerService()
|
||||
loggerService = new LoggerService()
|
||||
user = new UserService()
|
||||
|
||||
/**
|
||||
*
|
||||
* Create the Leader Board service
|
||||
* @param {String} leaders
|
||||
* @param {String} all
|
||||
* @param {String} configuration
|
||||
*/
|
||||
constructor() {
|
||||
|
||||
// necessary keys to interact with firebase
|
||||
// not a secret
|
||||
// https://stackoverflow.com/questions/37482366/is-it-safe-to-expose-firebase-apikey-to-the-public/37484053#37484053
|
||||
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
|
||||
const config = {
|
||||
apiKey: 'AIzaSyCTi_5Sm5dHFNf0d_Gn0MNWmlGheFBf6MQ',
|
||||
authDomain: 'moment-188701.firebaseapp.com',
|
||||
databaseURL: 'https://moment-188701.firebaseio.com',
|
||||
projectId: 'secure-moment-188701',
|
||||
storageBucket: 'secure-moment-188701.firebasestorage.app',
|
||||
messagingSenderId: '113827947104',
|
||||
appId: '1:113827947104:web:b176f746d8358302c51905',
|
||||
measurementId: 'G-LZRDY0TG46'
|
||||
}
|
||||
const app = initializeApp(config)
|
||||
this._store = getFirestore(app)
|
||||
|
||||
const configRef = doc(this.store, 'mw-config', 'configuration')
|
||||
getDoc(configRef)
|
||||
.then(res => {
|
||||
this.configuration = res.data()
|
||||
})
|
||||
}
|
||||
|
||||
get store() {
|
||||
return this._store
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the leader board
|
||||
* @param {String} level - the id of the game level
|
||||
* @param {String} title - the displayed name of the game level
|
||||
* @returns {HTMLDivElement} - element with the rendered leader board
|
||||
*/
|
||||
async update(level, title) {
|
||||
const displayElement = document.createElement('div')
|
||||
|
||||
this.lastPlace = Number.MAX_SAFE_INTEGER
|
||||
|
||||
const q = query(
|
||||
collection(this.store, 'mw-leaders', level, 'games'),
|
||||
orderBy('time'),
|
||||
limit(10)
|
||||
)
|
||||
this.topListSnapshot = await getDocs(q)
|
||||
this.renderList(displayElement, title, this.topListSnapshot.docs)
|
||||
|
||||
return displayElement
|
||||
}
|
||||
|
||||
renderList(displayElement, title, docs) {
|
||||
if (!displayElement) return
|
||||
|
||||
displayElement.innerHTML = ''
|
||||
const leaderHeading = document.createElement('h3')
|
||||
leaderHeading.innerText = title
|
||||
leaderHeading.style.borderBottom = '1px solid #c0c0c0'
|
||||
leaderHeading.style.paddingBottom = '10px'
|
||||
|
||||
|
||||
displayElement.style.maxWidth = '270px'
|
||||
displayElement.style.margin = '0 auto'
|
||||
|
||||
const leaderList = document.createElement('div')
|
||||
|
||||
leaderList.innerHTML = ''
|
||||
leaderList.style.listStyle = 'none'
|
||||
leaderList.style.textAlign = 'left'
|
||||
leaderList.style.marginTop = '-15px'
|
||||
|
||||
if (docs && docs.length) {
|
||||
let i = 1
|
||||
docs.forEach(game => {
|
||||
if (game) {
|
||||
const prettyTime = this.timerService.pretty(game.data().time)
|
||||
const name = game.data().name || 'Anonymous'
|
||||
const item = document.createElement('div')
|
||||
item.style.display = 'flex'
|
||||
const nameElement =document.createElement('div')
|
||||
nameElement.innerHTML = name
|
||||
nameElement.setAttribute('title', name)
|
||||
nameElement.style.textOverflow = 'ellipsis'
|
||||
nameElement.style.whiteSpace = 'nowrap'
|
||||
nameElement.style.overflow = 'hidden'
|
||||
nameElement.style.padding = '0 5px'
|
||||
nameElement.style.cursor = 'pointer'
|
||||
nameElement.style.fontWeight = 'bold'
|
||||
nameElement.style.fontStyle = 'italic'
|
||||
// nameElement.onmousedown = () => console.log(game.data());
|
||||
|
||||
const indexElement = document.createElement('div')
|
||||
indexElement.innerText = `#${i++}`
|
||||
|
||||
const timeElement = document.createElement('div')
|
||||
timeElement.innerText = prettyTime
|
||||
|
||||
item.append(indexElement, nameElement, timeElement)
|
||||
leaderList.append(item)
|
||||
}
|
||||
})
|
||||
if (docs.length >= 10) {
|
||||
this.lastPlace = docs[9].data().time
|
||||
}
|
||||
|
||||
displayElement.append(leaderHeading, leaderList)
|
||||
} else {
|
||||
const message = document.createElement('em')
|
||||
message.innerText = 'Be the first to the top!'
|
||||
displayElement.append(leaderHeading, message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async send(game, key) {
|
||||
const sessionId = new Date().toDateString().replace(/\s/g, '_')
|
||||
const gameId = new Date().toTimeString().replace(/\s/g, '_')
|
||||
const data = { }
|
||||
data[gameId] = game
|
||||
|
||||
const sessionRef = doc(this.store, 'mw-all', this.user.browserId, 'games', sessionId)
|
||||
await setDoc(sessionRef, data, { merge: true })
|
||||
|
||||
const winningCondigion = (
|
||||
this.configuration
|
||||
&& game.status === this.configuration.passingStatus
|
||||
&& game[key] < this.lastPlace
|
||||
)
|
||||
|
||||
if (winningCondigion) {
|
||||
let name = window.prompt(this.configuration.message)
|
||||
if (!name) {
|
||||
name = 'Anonymous'
|
||||
}
|
||||
|
||||
const newGame = {
|
||||
name,
|
||||
browserId: this.user.browserId,
|
||||
...game
|
||||
}
|
||||
|
||||
const gameScoreRef = doc(collection(this.store, 'mw-leaders', game.level, 'games'))
|
||||
await setDoc(gameScoreRef, newGame)
|
||||
}
|
||||
}
|
||||
|
||||
configurationPromt() {
|
||||
if (!this.configuration) {
|
||||
this.loggerService.debug('Failed to fetch server configuration. Please contact your developer.')
|
||||
}
|
||||
}
|
||||
}
|
||||
21
app/modules/user/user.js
Normal file
21
app/modules/user/user.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export class UserService {
|
||||
constructor() {
|
||||
if (!this.id) {
|
||||
this.browserId = this.generateId()
|
||||
}
|
||||
}
|
||||
|
||||
generateId() {
|
||||
|
||||
var nav = window.navigator
|
||||
var screen = window.screen
|
||||
var guid = nav.mimeTypes.length
|
||||
guid += nav.userAgent.replace(/\D+/g, '')
|
||||
guid += nav.plugins.length
|
||||
guid += screen.height || ''
|
||||
guid += screen.width || ''
|
||||
guid += screen.pixelDepth || ''
|
||||
|
||||
return guid
|
||||
}
|
||||
}
|
||||
17
app/package.json
Normal file
17
app/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "app",
|
||||
"version": "0.0.1",
|
||||
"description": "the mnswpr.com web app",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"build:preview": "npm run build && npm run preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ayo-run/mnswpr": "workspace:*",
|
||||
"firebase": "^12.11.0"
|
||||
},
|
||||
"author": "Ayo Ayco"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
2
app/vite-env.d.ts
vendored
Normal file
2
app/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite/types/importMeta.d.ts" />
|
||||
53
eslint.config.js
Normal file
53
eslint.config.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// @ts-check
|
||||
|
||||
import js from '@eslint/js'
|
||||
import css from '@eslint/css'
|
||||
import globals from 'globals'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import stylistic from '@stylistic/eslint-plugin'
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
files: ['**/*.css'],
|
||||
plugins: {
|
||||
css
|
||||
},
|
||||
languageOptions: {
|
||||
tolerant: true
|
||||
},
|
||||
language: 'css/css',
|
||||
rules: {
|
||||
'css/no-duplicate-imports': 'error',
|
||||
'css/no-empty-blocks': 'error',
|
||||
'css/no-invalid-at-rules': 'error',
|
||||
'css/no-invalid-properties': 'error'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,mjs,cjs}'],
|
||||
plugins: {
|
||||
js, '@stylistic': stylistic
|
||||
},
|
||||
extends: ['js/recommended'],
|
||||
languageOptions: {
|
||||
globals: globals.browser
|
||||
},
|
||||
rules: {
|
||||
'@stylistic/indent': ['error', 2],
|
||||
'@stylistic/quotes': ['error', 'single'],
|
||||
'@stylistic/semi': ['error', 'never'],
|
||||
'@stylistic/comma-dangle': ['error', 'never'] ,
|
||||
'@stylistic/block-spacing': 'error',
|
||||
'@stylistic/array-bracket-spacing': ['error', 'never'],
|
||||
'@stylistic/object-curly-spacing': ['error', 'always'],
|
||||
'@stylistic/key-spacing': ['error', {
|
||||
'beforeColon': false
|
||||
}],
|
||||
'@stylistic/array-bracket-newline': ['error', 'consistent'],
|
||||
'@stylistic/object-curly-newline': ['error', {
|
||||
'consistent': true
|
||||
}]
|
||||
}
|
||||
},
|
||||
globalIgnores(['**/dist'])
|
||||
])
|
||||
30
index.html
30
index.html
|
|
@ -1,30 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
|
||||
<meta name="Description" content="Play Minesweeper online for FREE!" />
|
||||
<title>Minesweeper</title>
|
||||
|
||||
<link rel="stylesheet" href="main.bundle.css" />
|
||||
<link rel="shortcut icon" type="image/png" href="assets/favicon.ico" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="body-wrapper">
|
||||
<div id="app">
|
||||
Please use Chrome or Firefox.
|
||||
<br />
|
||||
<div class="lds-ellipsis"><div></div><div></div><div></div><div></div></div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Want to update how people show their support
|
||||
<script type='text/javascript' src='https://ko-fi.com/widgets/widget_2.js'></script><script type='text/javascript'>kofiwidget2.init('Buy me a coffee', '#29abe0', 'ayoayco');kofiwidget2.draw();</script>
|
||||
-->
|
||||
</div>
|
||||
|
||||
<script type="module" src="./src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
11
instructions
11
instructions
|
|
@ -1,11 +0,0 @@
|
|||
<div id="instructions" class="hint-wrapper">
|
||||
<h1 class="pointer">Instructions</h1>
|
||||
<ol class="body instructions">
|
||||
<li>Clicking a cell which doesn't have a bomb reveals the number of surrounding bombs. Use this information plus some guess work to avoid opening the bombs.</li>
|
||||
<li>To open a cell, click on it. To flag a cell you think is a bomb, right-click.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div id="pro-tip" class="hint-wrapper">
|
||||
<h1 class="pointer">Pro Tip</h1>
|
||||
<span class="body hint">Clicking an open cell that has the correct number of flagged neighboring bombs will open all remaining unopened neighbor cells all at once. If an incorrect number of neighbors are flagged, or all neighbors are flagged or open, clicking the cell has no effect. If an incorrect neighbor is flagged, this will cause instant death.</span>
|
||||
</div>
|
||||
24
lib/LICENSE
Normal file
24
lib/LICENSE
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
BSD 2-Clause License
|
||||
|
||||
Copyright (c) 2019, Ayo Ayco
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
157
lib/README.md
Normal file
157
lib/README.md
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
# Build your own web browser game with `mnswpr`
|
||||
|
||||
Have you ever wondered how games on a web browser are built? Believe it or not, anything you see on a web browser can be built by anyone. That's why the web is so great: it is free and open for everyone to enjoy!
|
||||
|
||||
In this guide, we will use **mnswpr** as a simple building block for you to build your own browser game. I will walk you through the steps to create your own Minesweeper browser game from scratch.
|
||||
|
||||
If you want to skip to the ending, all the code are in this repository: [minimal-mnswpr](https://github.com/ayo-run/minimal-mnswpr)
|
||||
|
||||
First, let's go through the requirements.
|
||||
|
||||
## Requirements
|
||||
|
||||
It is assumed that you have some knowledge in HTML and JavaScript. You can easily read about this and play around examples online. Some knowledge on using a terminal and a text editor is also required.
|
||||
|
||||
You will need a computer with [node.js](https://nodejs.org/en/download).
|
||||
|
||||
If you are familiar with HTML and JavaScript, and has a computer with `node.js` installed... let's now start with the project setup!
|
||||
|
||||
## Project Setup
|
||||
|
||||
Open the terminal and confirm that you have `node.js`.
|
||||
|
||||
```bash
|
||||
# verify the node.js version
|
||||
node --version # Should print the version
|
||||
```
|
||||
Next, create a directory where we'll write some code for your game.
|
||||
|
||||
```bash
|
||||
# on mac or linux
|
||||
mkdir my-game
|
||||
|
||||
cd my-game
|
||||
```
|
||||
Once your terminal is in the new directory `my-game`, we will initialize the JavaScript project using the Node Package Manager or `npm`. Type the following on your terminal:
|
||||
|
||||
```bash
|
||||
npm init
|
||||
```
|
||||
|
||||
This will start the `npm` initialization interface, which will ask you some questions. You can think of what you want to answer, but if you want to go with the defaults, you can just press the `Enter` key repeatedly for each until the questions are done.
|
||||
|
||||
The last question will ask you if everything is OK:
|
||||
|
||||
```bash
|
||||
Is this OK? (yes)
|
||||
|
||||
# Don't be shy, you can just press ENTER again
|
||||
```
|
||||
|
||||
Next, we will add `vite` as a development tool for bundling and as a development server.
|
||||
|
||||
<details>
|
||||
<summary>Additional info on Vite...</summary>
|
||||
Making web pages work in different browsers often brings challenges brought about by differences in technological implementations and limitations. Vite helps us so that our code will work in different environments without us worrying about issues in compatibility and performance. </details><br />
|
||||
|
||||
```bash
|
||||
npm i -D vite
|
||||
```
|
||||
|
||||
Now that the JS project is initialized and we have a development environment with `vite`, we will install **mnswpr** as a dependency:
|
||||
|
||||
```bash
|
||||
npm i @ayo-run/mnswpr
|
||||
```
|
||||
|
||||
Finally, you can run the installed `vite` dev server by running the following:
|
||||
|
||||
```bash
|
||||
# `npx` here is the execute command for npm
|
||||
npx vite # will run the vite dev server
|
||||
```
|
||||
|
||||
Vite will now show the address you can type to your browser to see your project. It will show something like this:
|
||||
|
||||
```bash
|
||||
VITE v8.0.3 ready in 128 ms
|
||||
|
||||
➜ Local: http://localhost:5173/
|
||||
➜ Network: use --host to expose
|
||||
➜ press h + enter to show help
|
||||
```
|
||||
|
||||
You can then open the "Local" address (e.g., http://localhost:5173) on your browser.
|
||||
|
||||
Congratulations. You now have your project setup! It's time to write some code.
|
||||
|
||||
## Write Some Code
|
||||
|
||||
Believe it or not, you have done the hard part. Now we start the fun part: putting the parts of your game together!
|
||||
|
||||
There are mainly 3 kinds of code that work together in a web page: HTML, JavaScript or JS, and Cascading Style Sheets or CSS.
|
||||
|
||||
In this guide, we work mostly with HTML & JS to focus on the basics.
|
||||
|
||||
### The HTML
|
||||
|
||||
Using your favorite text editor, create a file named `index.html` with the following content:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>My Minesweeper Game</title>
|
||||
<style>
|
||||
html, body {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>My Minesweeper Game</h1>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Additional info on `index.html`</summary>
|
||||
The file name `index.html` is important. It is the default file that Web browsers look for in any given path/directory as the web page it will show.
|
||||
</details>
|
||||
<br />
|
||||
|
||||
If you have your browser opened to the Local address `vite` just showed earlier, you should see your very first web page with a title **My Minesweeper Game**
|
||||
|
||||
Exciting right? You can try editing the text inside `<h1>...</h1>` to see the web page change as well. :)
|
||||
|
||||
Take a second to read through the content of your `index.html`. The `<div>` element there with `id="app"` attribute will be where the game board will be rendered.
|
||||
|
||||
Now we just need JavaScript to do this. You will find the `<script>` tag that has the `src="main.js"` attribute, which means the web page is ready to load that JavaScript... but this file doesn't exist yet. So let's write the code for that.
|
||||
|
||||
### The JavaScript
|
||||
|
||||
Create a new file named `main.js` with the following content:
|
||||
|
||||
```js
|
||||
/**
|
||||
* main.js
|
||||
*/
|
||||
import '@ayo-run/mnswpr/mnswpr.css'
|
||||
import mnswpr from '@ayo-run/mnswpr'
|
||||
|
||||
const game = new mnswpr('app')
|
||||
game.initialize()
|
||||
```
|
||||
|
||||
When you create this `main.js` file, the dev server will instantly update the web page for you and you should now see your minesweeper browser game!
|
||||
|
||||
|
||||
---
|
||||
|
||||
_Just keep building._<br>
|
||||
_A project by [Ayo](https://ayo.ayco.io)_
|
||||
31
lib/levels.js
Normal file
31
lib/levels.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
export const levels = {
|
||||
beginner: {
|
||||
rows: 9,
|
||||
cols: 9,
|
||||
mines: 10,
|
||||
id: 'beginner',
|
||||
name: 'Noobs'
|
||||
},
|
||||
intermediate: {
|
||||
rows: 16,
|
||||
cols: 16,
|
||||
mines: 40,
|
||||
id: 'intermediate',
|
||||
name: 'Normies'
|
||||
},
|
||||
expert: {
|
||||
rows: 16,
|
||||
cols: 30,
|
||||
mines: 99,
|
||||
id: 'expert',
|
||||
name: 'Torment'
|
||||
},
|
||||
nightmare: {
|
||||
rows: 20,
|
||||
cols: 30,
|
||||
mines: 150,
|
||||
id: 'nightmare',
|
||||
name: 'Hell'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,39 +1,3 @@
|
|||
/*
|
||||
initial code from: https://codepen.io/101Computing/pen/wEbEqx
|
||||
*/
|
||||
|
||||
/* helpers */
|
||||
.float-left {
|
||||
float: left;
|
||||
}
|
||||
.float-right {
|
||||
float: right;
|
||||
}
|
||||
.clear-both {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
body {
|
||||
background: black;
|
||||
color: #DDDDDD;
|
||||
font-family: medium-content-sans-serif-font, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
em {
|
||||
font-weight: bold;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
background: -webkit-linear-gradient(90deg,#ff8a00,#e52e71);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
-moz-user-select: none; -webkit-user-select: none; -ms-user-select:none; user-select:none; -o-user-select:none;
|
||||
}
|
||||
|
||||
#grid {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
|
@ -78,10 +42,6 @@ h1 {
|
|||
padding: 0px;
|
||||
}
|
||||
|
||||
/*
|
||||
#0000ff, #008100, #ff1300, #000083, #810500, #2a9494, #000000, #808080;
|
||||
*/
|
||||
|
||||
#grid TR TD[data-value="1"] {
|
||||
color: #0000ff !important;
|
||||
}
|
||||
|
|
@ -150,66 +110,22 @@ button {
|
|||
margin: 12px;
|
||||
}
|
||||
|
||||
/* The Modal (background) */
|
||||
.modal {
|
||||
position: fixed; /* Stay in place */
|
||||
z-index: 1; /* Sit on top */
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%; /* Full width */
|
||||
height: 100%; /* Full height */
|
||||
overflow: auto; /* Enable scroll if needed */
|
||||
background-color: rgb(0,0,0); /* Fallback color */
|
||||
background-color: rgba(0,0,0,0.4); /* Black w/ opacity */
|
||||
}
|
||||
|
||||
/* Modal Content/Box */
|
||||
.modal-content {
|
||||
background: #efffef;
|
||||
margin: 5% auto 0; /* 15% from the top and centered */
|
||||
padding: 20px;
|
||||
border: 5px solid #c0e0d0;
|
||||
width: 60%; /* Could be more or less, depending on screen size */
|
||||
box-shadow: 1px 1px 0px rgba(0, 0, 0, 0.2);
|
||||
border-radius: 7px;
|
||||
line-height: 36px!important;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
box-shadow: 1px 1px 0px rgba(0, 0, 0, 0.2);
|
||||
border-radius: 7px;
|
||||
line-height: 36px!important;
|
||||
min-width: 150px;
|
||||
display: inline-block!important;
|
||||
background-color: #29abe0;
|
||||
padding: 2px 12px !important;
|
||||
text-align: center !important;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
overflow-wrap: break-word;
|
||||
vertical-align: middle;
|
||||
border: 0 none #fff !important;
|
||||
font-family: 'Quicksand',Helvetica,Century Gothic,sans-serif !important;
|
||||
text-decoration: none;
|
||||
text-shadow: none;
|
||||
font-weight: 700!important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
p.announcement-action {
|
||||
font-size: normal;
|
||||
h1 {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-size: 32px;
|
||||
background: -webkit-linear-gradient(90deg,#ff8a00,#e52e71);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
-moz-user-select: none; -webkit-user-select: none; -ms-user-select:none; user-select:none; -o-user-select:none;
|
||||
}
|
||||
|
||||
#body-wrapper {
|
||||
display: inline-block;
|
||||
h1 span {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.btn-container {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
h1 sup {
|
||||
font-size: small
|
||||
}
|
||||
|
||||
/** mobile **/
|
||||
|
|
@ -222,9 +138,5 @@ p.announcement-action {
|
|||
min-width: 20px;
|
||||
min-height: 20px;
|
||||
}
|
||||
.btn-container {
|
||||
position: inherit;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
|
||||
844
lib/mnswpr.js
Normal file
844
lib/mnswpr.js
Normal file
|
|
@ -0,0 +1,844 @@
|
|||
// @ts-check
|
||||
|
||||
/**
|
||||
* import styles for vite bundling
|
||||
*/
|
||||
import './mnswpr.css'
|
||||
|
||||
import {
|
||||
LoggerService,
|
||||
StorageService,
|
||||
TimerService
|
||||
} from '../utils/index.js'
|
||||
import { levels } from './levels.js'
|
||||
|
||||
const TEST_MODE = false // set to true if you want to test the game with visual hints
|
||||
const MOBILE_BUSY_DELAY = 250
|
||||
const PC_BUSY_DELAY = 500
|
||||
|
||||
/**
|
||||
* Create Minesweeper game board
|
||||
* @param {String} appId
|
||||
* @param {String} version
|
||||
* @param {{
|
||||
* levelChanged: (setting: any) => void,
|
||||
* gameDone: (game: any) => void
|
||||
* } | undefined } hooks
|
||||
*/
|
||||
const Minesweeper = function(appId, version, hooks = undefined) {
|
||||
const _this = this
|
||||
const storageService = new StorageService()
|
||||
const timerService = new TimerService()
|
||||
const loggerService = new LoggerService()
|
||||
|
||||
if (!hooks) {
|
||||
hooks = {
|
||||
levelChanged: () => {},
|
||||
gameDone: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
let grid = document.createElement('table')
|
||||
grid.setAttribute('id', 'grid')
|
||||
let flagsDisplay = document.createElement('span')
|
||||
let smileyDisplay = document.createElement('span')
|
||||
let timerDisplay = document.createElement('span')
|
||||
let appElement = document.getElementById(appId)
|
||||
if (!appElement) {
|
||||
const body = document.getElementsByTagName('body')[0]
|
||||
appElement = document.createElement('div')
|
||||
body.append(appElement)
|
||||
}
|
||||
|
||||
let isMobile = false
|
||||
let isLeft = false
|
||||
let isRight = false
|
||||
let pressed = undefined
|
||||
let bothPressed = undefined
|
||||
let skip = false
|
||||
let skipCondition = false
|
||||
let mouseUpCallBackArray = [
|
||||
clickCell,
|
||||
middleClickCell
|
||||
]
|
||||
let mouseDownCallBackArray = [
|
||||
highlightCell, // left-click down
|
||||
highlightSurroundingCell, // middle-click down
|
||||
rightClickCell // right-click down
|
||||
]
|
||||
let firstClick = true
|
||||
let isBusy = false
|
||||
let clickedCell
|
||||
let cachedSetting = storageService.getFromLocal('setting')
|
||||
let setting = cachedSetting || levels.beginner
|
||||
if (TEST_MODE) {
|
||||
setting = {
|
||||
rows: 10,
|
||||
cols: 10,
|
||||
mines: 10,
|
||||
id: 'test',
|
||||
name: 'test'
|
||||
}
|
||||
}
|
||||
storageService.saveToLocal('setting', setting)
|
||||
let flagsCount = setting.mines
|
||||
let minesArray = []
|
||||
|
||||
this.initialize = function() {
|
||||
const headingElement = document.createElement('h1')
|
||||
const gameBoard = document.createElement('div')
|
||||
|
||||
headingElement.innerHTML = `<span>Minesweeper</span><sup>${version}</sup>`
|
||||
document.title = `mnswpr [${version}]`
|
||||
gameBoard.setAttribute('id', 'game-board')
|
||||
gameBoard.append(initializeToolbar(), grid, initializeFootbar())
|
||||
if(appElement) {
|
||||
appElement.innerHTML = ''
|
||||
appElement.append(headingElement, gameBoard)
|
||||
}
|
||||
generateGrid({ initial: true })
|
||||
}
|
||||
|
||||
function initializeFootbar() {
|
||||
const footBar = document.createElement('div')
|
||||
|
||||
const resetButton = document.createElement('button')
|
||||
resetButton.innerText = 'Reset'
|
||||
resetButton.onmousedown = () => generateGrid()
|
||||
footBar.append(resetButton)
|
||||
|
||||
let levelsDropdown = document.createElement('select')
|
||||
levelsDropdown.onchange = () => updateSetting(levelsDropdown.value)
|
||||
|
||||
const levelsKeys = Object.keys(levels)
|
||||
levelsKeys.forEach(key => {
|
||||
const levelOption = document.createElement('option')
|
||||
levelOption.value = levels[key].id
|
||||
levelOption.text = levels[key].name
|
||||
if (setting.id === levelOption.value) {
|
||||
levelOption.selected = true
|
||||
}
|
||||
levelsDropdown.add(levelOption, null)
|
||||
})
|
||||
|
||||
|
||||
|
||||
if (TEST_MODE) {
|
||||
const testLevel = document.createElement('span')
|
||||
testLevel.innerText = 'Test Mode'
|
||||
footBar.append(testLevel)
|
||||
} else {
|
||||
footBar.append(levelsDropdown)
|
||||
}
|
||||
|
||||
return footBar
|
||||
}
|
||||
|
||||
function initializeToolbar() {
|
||||
const toolbar = document.createElement('div')
|
||||
const toolbarItems = []
|
||||
|
||||
const flagsWrapper = document.createElement('div')
|
||||
flagsWrapper.append(flagsDisplay)
|
||||
flagsWrapper.style.height = '20px'
|
||||
toolbar.append(flagsWrapper)
|
||||
toolbarItems.push(flagsWrapper)
|
||||
|
||||
const smileyWrapper = document.createElement('div')
|
||||
smileyWrapper.append(smileyDisplay)
|
||||
// toolbar.append(smileyWrapper);
|
||||
// toolbarItems.push(smileyWrapper);
|
||||
|
||||
const timerWrapper = document.createElement('div')
|
||||
timerWrapper.append(timerDisplay)
|
||||
timerWrapper.style.height = '20px'
|
||||
toolbar.append(timerWrapper)
|
||||
toolbarItems.push(timerWrapper)
|
||||
|
||||
toolbar.style.cursor = 'pointer'
|
||||
toolbar.style.padding = '10px 35px'
|
||||
toolbar.style.display = 'flex'
|
||||
toolbar.style.justifyContent = 'space-between'
|
||||
toolbar.onmousedown = () => generateGrid()
|
||||
|
||||
return toolbar
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the game level
|
||||
* @param {String} key
|
||||
*/
|
||||
function updateSetting(key) {
|
||||
setting = levels[key]
|
||||
storageService.saveToLocal('setting', setting)
|
||||
generateGrid({ initial: true })
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generate the Game Board
|
||||
* @param {{
|
||||
* initial: boolean
|
||||
* }} options - Game Board Options
|
||||
*/
|
||||
function generateGrid(options = { initial: false }) {
|
||||
firstClick = true
|
||||
grid.innerHTML = ''
|
||||
grid.oncontextmenu = () => false
|
||||
flagsCount = setting.mines
|
||||
minesArray = []
|
||||
|
||||
for (let i = 0; i < setting.rows; i++) {
|
||||
let row = grid.insertRow(i)
|
||||
row.oncontextmenu = () => false
|
||||
for (let j=0; j<setting.cols; j++) {
|
||||
let cell = row.insertCell(j)
|
||||
initializeEventHandlers(cell)
|
||||
|
||||
if ('ontouchstart' in document.documentElement) {
|
||||
isMobile = true
|
||||
initializeTouchEventHandlers(cell)
|
||||
}
|
||||
|
||||
let status = document.createAttribute('data-status')
|
||||
status.value = 'default'
|
||||
cell.setAttributeNode(status)
|
||||
}
|
||||
}
|
||||
|
||||
let gameStatus = document.createAttribute('game-status')
|
||||
gameStatus.value = 'inactive'
|
||||
grid.setAttributeNode(gameStatus)
|
||||
|
||||
if (appElement) {
|
||||
appElement.style.margin = '0 auto'
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: add hook afterGridGenerated
|
||||
* - for initializing the leaderboard
|
||||
*/
|
||||
if (options.initial)
|
||||
hooks.levelChanged(setting)
|
||||
|
||||
timerService.initialize(timerDisplay)
|
||||
updateFlagsCountDisplay()
|
||||
addMines(setting.mines)
|
||||
|
||||
}
|
||||
|
||||
function setBusy() {
|
||||
isBusy = true
|
||||
if (isMobile) {
|
||||
setTimeout(() => isBusy = false, MOBILE_BUSY_DELAY)
|
||||
} else {
|
||||
setTimeout(() => isBusy = false, PC_BUSY_DELAY)
|
||||
}
|
||||
}
|
||||
|
||||
function updateFlagsCountDisplay(count = flagsCount) {
|
||||
if (grid.getAttribute('game-status') != 'win') {
|
||||
flagsDisplay.innerHTML = `${count}`
|
||||
return
|
||||
}
|
||||
flagsDisplay.innerHTML = '😁'
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLTableCellElement} cell
|
||||
*/
|
||||
function initializeTouchEventHandlers(cell) {
|
||||
let ontouchleave = function() {
|
||||
if (clickedCell === this) {
|
||||
clickedCell = undefined
|
||||
}
|
||||
}
|
||||
cell.addEventListener('touchleave', ontouchleave)
|
||||
|
||||
let ontouchend = function() {
|
||||
endTouchTimer()
|
||||
}
|
||||
cell.addEventListener('touchend', ontouchend)
|
||||
|
||||
let ontouchstart = function(e) {
|
||||
isMobile = true
|
||||
if (!isBusy && typeof e === 'object') {
|
||||
startTouchTimer(this)
|
||||
}
|
||||
}
|
||||
cell.addEventListener('touchstart', ontouchstart)
|
||||
|
||||
}
|
||||
|
||||
function initializeEventHandlers(_cell) {
|
||||
|
||||
let cell = _cell
|
||||
skip = false
|
||||
skipCondition = false
|
||||
|
||||
resetMouseEventFlags()
|
||||
|
||||
document.onkeydown = function(e) {
|
||||
if (e.keyCode == 32 || e.keyCode == 113) {
|
||||
generateGrid()
|
||||
if ('preventDefault' in e) {
|
||||
e.preventDefault()
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
resetMouseEventFlags()
|
||||
}
|
||||
|
||||
window.onblur = function() {
|
||||
resetMouseEventFlags()
|
||||
}
|
||||
|
||||
grid.onmouseleave = function() {
|
||||
removeHighlights()
|
||||
}
|
||||
document.oncontextmenu = () => false
|
||||
document.onmouseup = function() {
|
||||
resetMouseEventFlags()
|
||||
}
|
||||
document.onmousedown = function(e) {
|
||||
isMobile = false
|
||||
switch (e.button) {
|
||||
case 0: pressed = 'left'; isLeft = true; break
|
||||
case 1: pressed = 'middle'; break
|
||||
case 2: isRight = true; break
|
||||
}
|
||||
}
|
||||
|
||||
// Set grid status to active on first click
|
||||
cell.onmouseup = function(e) {
|
||||
pressed = undefined
|
||||
let dont = false
|
||||
|
||||
if (bothPressed) {
|
||||
bothPressed = false
|
||||
if (e.button == '2') {
|
||||
skipCondition = true
|
||||
} else if (e.button == '0') {
|
||||
dont = true
|
||||
}
|
||||
if (getStatus(this) == 'clicked') {
|
||||
middleClickCell(this)
|
||||
return
|
||||
}
|
||||
}
|
||||
switch(e.button) {
|
||||
case 0: {
|
||||
isLeft = false
|
||||
if (skipCondition) {
|
||||
skip = true
|
||||
}
|
||||
break
|
||||
}
|
||||
case 2: isRight = false; break
|
||||
}
|
||||
removeHighlights()
|
||||
if (skip || dont) {
|
||||
skip = false
|
||||
skipCondition = false
|
||||
return
|
||||
}
|
||||
if (!isBusy && typeof e === 'object' && e.button != 2) {
|
||||
mouseUpCallBackArray[e.button].call(_this, this)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
cell.onmousedown = function(e) {
|
||||
skip = false
|
||||
if (!isBusy && typeof e === 'object') {
|
||||
switch(e.button) {
|
||||
case 0: isLeft = true; break
|
||||
case 2: isRight = true; break
|
||||
}
|
||||
|
||||
if (isLeft && isRight) {
|
||||
bothPressed = true
|
||||
highlightSurroundingCell(this)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.button == '1') {
|
||||
pressed = 'middle'
|
||||
highlightSurroundingCell(this)
|
||||
} else if (e.button == '0') {
|
||||
pressed = 'left'
|
||||
if (getStatus(this) == 'clicked') {
|
||||
highlightSurroundingCell(this)
|
||||
} else {
|
||||
highlightCell(this)
|
||||
}
|
||||
}
|
||||
|
||||
if (e.button == '2') mouseDownCallBackArray[e.button].call(_this, this)
|
||||
}
|
||||
}
|
||||
|
||||
cell.onmousemove = function(e) {
|
||||
if ((pressed || bothPressed) && typeof e === 'object') {
|
||||
removeHighlights()
|
||||
/*
|
||||
if (!isEqual(clickedCell, cell)) {
|
||||
clickedCell = undefined;
|
||||
}
|
||||
*/
|
||||
if (pressed == 'middle' || (isLeft && isRight)) {
|
||||
highlightSurroundingCell(this)
|
||||
} else if (pressed == 'left') {
|
||||
if (getStatus(this) == 'clicked') {
|
||||
highlightSurroundingCell(this)
|
||||
} else {
|
||||
highlightCell(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cell.oncontextmenu = () => false
|
||||
cell.onselectstart = () => false
|
||||
cell.setAttribute('unselectable', 'on')
|
||||
}
|
||||
|
||||
function isEqual(x, y) {
|
||||
if (!x) return false
|
||||
return x === y
|
||||
}
|
||||
|
||||
function startTouchTimer(cell) {
|
||||
if (isEqual(clickedCell, cell)) {
|
||||
return
|
||||
}
|
||||
clickedCell = cell
|
||||
setTimeout(() => {
|
||||
if (isEqual(clickedCell, cell)) {
|
||||
rightClickCell(cell)
|
||||
setBusy()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function endTouchTimer() {
|
||||
clickedCell = undefined
|
||||
}
|
||||
|
||||
function resetMouseEventFlags() {
|
||||
pressed = undefined
|
||||
bothPressed = undefined
|
||||
isLeft = false
|
||||
isRight = false
|
||||
removeHighlights()
|
||||
skip = true
|
||||
}
|
||||
|
||||
function addMines(minesCount) {
|
||||
//Add mines randomly
|
||||
for (let i=0; i<minesCount; i++) {
|
||||
let row = Math.floor(Math.random() * setting.rows)
|
||||
let col = Math.floor(Math.random() * setting.cols)
|
||||
let cell = grid.rows[row].cells[col]
|
||||
if (isMine(cell)) {
|
||||
transferMine()
|
||||
} else {
|
||||
minesArray.push([row, col])
|
||||
}
|
||||
if (TEST_MODE){
|
||||
cell.innerHTML = 'X'
|
||||
}
|
||||
}
|
||||
if (TEST_MODE) {
|
||||
printMines()
|
||||
}
|
||||
}
|
||||
|
||||
function revealMines() {
|
||||
if (grid.getAttribute('game-status') == 'done') return
|
||||
//Highlight all mines in red
|
||||
const win = grid.getAttribute('game-status') == 'win'
|
||||
for (let i=0; i<setting.rows; i++) {
|
||||
for(let j=0; j<setting.cols; j++) {
|
||||
let cell = grid.rows[i].cells[j]
|
||||
if (win) {
|
||||
handleWinRevelation(cell)
|
||||
} else {
|
||||
handleLostRevelation(cell)
|
||||
}
|
||||
}
|
||||
}
|
||||
grid.setAttribute('game-status', 'done')
|
||||
|
||||
const time = timerService.stop()
|
||||
const game = {
|
||||
time,
|
||||
status: win ? 'win' : 'loss',
|
||||
level: setting.id,
|
||||
time_stamp: new Date(),
|
||||
isMobile
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: add hook after gameSession send back `game`
|
||||
* - for sending the game score to the db
|
||||
*/
|
||||
hooks.gameDone(game)
|
||||
|
||||
}
|
||||
|
||||
function handleWinRevelation(cell) {
|
||||
updateFlagsCountDisplay(0)
|
||||
if (isMine(cell)) {
|
||||
cell.innerHTML = ':)'
|
||||
cell.className = 'correct'
|
||||
setStatus(cell, 'clicked')
|
||||
let correct = document.createAttribute('title')
|
||||
correct.value = 'Correct'
|
||||
cell.setAttributeNode(correct)
|
||||
setStatus(cell, 'clicked')
|
||||
}
|
||||
}
|
||||
|
||||
function handleLostRevelation(cell) {
|
||||
if (isFlagged(cell)) {
|
||||
cell.className = 'flag'
|
||||
if (!isMine(cell)) {
|
||||
cell.innerHTML = 'X'
|
||||
cell.className = 'wrong'
|
||||
let wrong = document.createAttribute('title')
|
||||
wrong.value = 'Wrong'
|
||||
cell.setAttributeNode(wrong)
|
||||
} else {
|
||||
cell.innerHTML = ':)'
|
||||
cell.className = 'correct'
|
||||
let correct = document.createAttribute('title')
|
||||
correct.value = 'Correct'
|
||||
cell.setAttributeNode(correct)
|
||||
}
|
||||
} else {
|
||||
if (isMine(cell)) {
|
||||
cell.className = 'mine'
|
||||
setStatus(cell, 'clicked')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isOpen(cell) {
|
||||
return cell.innerHTML !== '' && !isFlagged(cell)
|
||||
}
|
||||
|
||||
function isFlagged(cell) {
|
||||
return getStatus(cell) == 'flagged'
|
||||
}
|
||||
|
||||
function isMine(cell) {
|
||||
return getIndex(minesArray, cell) > -1
|
||||
}
|
||||
|
||||
function removeItem(arr, cell) {
|
||||
const index = getIndex(arr, cell)
|
||||
if (index > -1) {
|
||||
arr.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function getIndex(arr, cell) {
|
||||
const row = cell.parentNode.rowIndex
|
||||
const col = cell.cellIndex
|
||||
let index = -1
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
let rowCol = arr[i]
|
||||
if (rowCol[0] === row && rowCol[1] === col) {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
function checkLevelCompletion() {
|
||||
let levelComplete = true
|
||||
for (let i=0; i<setting.rows; i++) {
|
||||
for(let j=0; j<setting.cols; j++) {
|
||||
const cell = grid.rows[i].cells[j]
|
||||
if (!isMine(cell) && cell.innerHTML=='') levelComplete=false
|
||||
}
|
||||
}
|
||||
if (levelComplete && grid.getAttribute('game-status') == 'active') {
|
||||
grid.setAttribute('game-status', 'win')
|
||||
revealMines()
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(cell, status) {
|
||||
cell.setAttribute('data-status', status)
|
||||
}
|
||||
|
||||
function getCol(cell) {
|
||||
return cell.cellIndex
|
||||
}
|
||||
|
||||
function getRow(cell) {
|
||||
return cell.parentNode.rowIndex
|
||||
}
|
||||
|
||||
function getStatus(cell) {
|
||||
if (!cell) return undefined
|
||||
return cell.getAttribute('data-status')
|
||||
}
|
||||
|
||||
function middleClickCell(cell) {
|
||||
if (grid.getAttribute('game-status') != 'active' || getStatus(cell) !== 'clicked') {
|
||||
return
|
||||
}
|
||||
// check for number of surrounding flags
|
||||
const valueString = cell.getAttribute('data-value')
|
||||
let cellValue = parseInt(valueString, 10)
|
||||
let flagCount = countFlagsAround(cell)
|
||||
|
||||
if (flagCount === cellValue) {
|
||||
clickSurrounding(cell)
|
||||
if (TEST_MODE) loggerService.debug('middle click', cell)
|
||||
}
|
||||
}
|
||||
|
||||
function countFlagsAround(cell) {
|
||||
let flagCount = 0
|
||||
let cellRow = cell.parentNode.rowIndex
|
||||
let cellCol = cell.cellIndex
|
||||
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
|
||||
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
|
||||
if (isFlagged(grid.rows[i].cells[j])) flagCount++
|
||||
}
|
||||
}
|
||||
return flagCount
|
||||
}
|
||||
|
||||
function clickSurrounding(cell) {
|
||||
if (grid.getAttribute('game-status') != 'active') return
|
||||
let cellRow = cell.parentNode.rowIndex
|
||||
let cellCol = cell.cellIndex
|
||||
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
|
||||
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
|
||||
let currentCell = grid.rows[i].cells[j]
|
||||
if (getStatus(currentCell) == 'flagged') continue
|
||||
openCell(currentCell)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function increaseFlagsCount() {
|
||||
flagsCount++
|
||||
updateFlagsCountDisplay()
|
||||
}
|
||||
|
||||
function decreaseFlagsCount() {
|
||||
flagsCount--
|
||||
updateFlagsCountDisplay()
|
||||
}
|
||||
|
||||
function activateGame() {
|
||||
grid.setAttribute('game-status', 'active')
|
||||
// start timer
|
||||
timerService.start()
|
||||
}
|
||||
|
||||
function gameIsDone() {
|
||||
return grid.getAttribute('game-status') == 'over' || grid.getAttribute('game-status') == 'done'
|
||||
}
|
||||
|
||||
function removeHighlights() {
|
||||
for (let i=0; i<setting.rows; i++) {
|
||||
const rows = grid.rows[i]
|
||||
if (!rows) continue
|
||||
for(let j=0; j<setting.cols; j++) {
|
||||
let currentCell = grid.rows[i].cells[j]
|
||||
if (getStatus(currentCell) == 'highlighted') setStatus(currentCell, 'default')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function highlightCell(cell) {
|
||||
if (isFlagged(cell)) return
|
||||
if (!gameIsDone() && getStatus(cell) == 'default') setStatus(cell, 'highlighted') // currentCell.classList.add('highlight');
|
||||
}
|
||||
|
||||
function highlightSurroundingCell(cell) {
|
||||
let cellRow = cell.parentNode.rowIndex
|
||||
let cellCol = cell.cellIndex
|
||||
|
||||
highlightCell(cell)
|
||||
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
|
||||
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
|
||||
let currentCell = grid.rows[i].cells[j]
|
||||
highlightCell(currentCell)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rightClickCell(cell) {
|
||||
if (isFlagged(cell)) setBusy()
|
||||
if (grid.getAttribute('game-status') == 'inactive') {
|
||||
activateGame()
|
||||
}
|
||||
if (grid.getAttribute('game-status') != 'active') return
|
||||
if (getStatus(cell) != 'clicked' && getStatus(cell) != 'empty') {
|
||||
if (getStatus(cell) == 'default' || getStatus(cell) == 'highlighted') {
|
||||
if (flagsCount <= 0) return
|
||||
cell.className = 'flag'
|
||||
decreaseFlagsCount()
|
||||
setStatus(cell, 'flagged')
|
||||
} else {
|
||||
cell.className = ''
|
||||
increaseFlagsCount()
|
||||
setStatus(cell, 'default')
|
||||
}
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate(100)
|
||||
}
|
||||
if (TEST_MODE) loggerService.debug('right click', cell)
|
||||
}
|
||||
}
|
||||
|
||||
function clickCell(cell) {
|
||||
if (isFlagged(cell)) setBusy()
|
||||
if (grid.getAttribute('game-status') == 'inactive') {
|
||||
activateGame()
|
||||
}
|
||||
if (grid.getAttribute('game-status') != 'active') return
|
||||
//Check if the end-user clicked on a mine
|
||||
if (TEST_MODE) loggerService.debug('click', cell)
|
||||
if (getStatus(cell) == 'flagged' || grid.getAttribute('game-status') == 'over') {
|
||||
return
|
||||
} else if (getStatus(cell) == 'clicked') {
|
||||
middleClickCell(cell)
|
||||
return
|
||||
} else if (isMine(cell) && firstClick) {
|
||||
// cell.setAttribute('data-mine', 'false');
|
||||
removeItem(minesArray, cell)
|
||||
transferMine(cell)
|
||||
if (TEST_MODE) printMines()
|
||||
}
|
||||
|
||||
openCell(cell)
|
||||
}
|
||||
|
||||
function printMines() {
|
||||
let count = 0
|
||||
for (let i = 0; i < setting.rows; i++) {
|
||||
for (let j = 0; j < setting.cols; j++) {
|
||||
if (isMine(grid.rows[i].cells[j])) {
|
||||
loggerService.debug(count++ + ' - mine: [' + i + ',' + j + ']')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function transferMine(cell = undefined) {
|
||||
let found = false
|
||||
do {
|
||||
let row = Math.floor(Math.random() * setting.rows)
|
||||
let col = Math.floor(Math.random() * setting.cols)
|
||||
const transferMineToCell = grid.rows[row].cells[col]
|
||||
if (isMine(transferMineToCell) || isNeighbor(cell, transferMineToCell)) {
|
||||
continue
|
||||
} else {
|
||||
minesArray.push([row, col])
|
||||
if (TEST_MODE){
|
||||
transferMineToCell.innerHTML = 'X'
|
||||
if (TEST_MODE) loggerService.debug('transferred mine to: ' + row + ', ' + col)
|
||||
}
|
||||
// TODO: refactor maybe
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
found = true
|
||||
return
|
||||
}
|
||||
} while(!found)
|
||||
}
|
||||
|
||||
function isNeighbor(cell, nextCell) {
|
||||
if (cell === undefined) {
|
||||
return
|
||||
}
|
||||
const rowDifference = Math.abs(getRow(cell) - getRow(nextCell))
|
||||
const colDifference = Math.abs(getCol(cell) - getCol(nextCell))
|
||||
|
||||
return (rowDifference === 1) && (colDifference === 1)
|
||||
}
|
||||
|
||||
function countMinesAround(cell) {
|
||||
let mineCount=0
|
||||
let cellRow = cell.parentNode.rowIndex
|
||||
let cellCol = cell.cellIndex
|
||||
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1,setting.rows-1); i++) {
|
||||
const rows = grid.rows[i]
|
||||
if (!rows) continue
|
||||
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1,setting.cols-1); j++) {
|
||||
const cell = rows.cells[j]
|
||||
const mine = isMine(cell)
|
||||
if (cell && mine) {
|
||||
mineCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
return mineCount
|
||||
}
|
||||
|
||||
function updateCellValue(cell, value) {
|
||||
const spanElement = document.createElement('span')
|
||||
spanElement.innerHTML = value
|
||||
cell.innerHTML = ''
|
||||
cell.appendChild(spanElement)
|
||||
}
|
||||
|
||||
function handleEmpty(cell) {
|
||||
updateCellValue(cell, ' ')
|
||||
let cellRow = cell.parentNode.rowIndex
|
||||
let cellCol = cell.cellIndex
|
||||
setStatus(cell, 'empty')
|
||||
//Reveal all adjacent cells as they do not have a mine
|
||||
for (let y = Math.max(cellRow-1,0); y <= Math.min(cellRow+1, setting.rows - 1); y++) {
|
||||
const rows = grid.rows[y]
|
||||
if (!rows) continue
|
||||
for(let x = Math.max(cellCol-1,0); x <= Math.min(cellCol+1, setting.cols - 1); x++) {
|
||||
//Recursive Call
|
||||
const cell = rows.cells[x]
|
||||
if (cell && !isOpen(cell)) {
|
||||
clickCell(cell)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openCell(cell) {
|
||||
if (grid.getAttribute('game-status') != 'active') return
|
||||
|
||||
cell.className='clicked'
|
||||
setStatus(cell, 'clicked')
|
||||
firstClick = false
|
||||
|
||||
if (isMine(cell)) {
|
||||
revealMines()
|
||||
flagsDisplay.innerHTML = '😱'
|
||||
grid.setAttribute('game-status', 'over')
|
||||
} else {
|
||||
const mineCount = countMinesAround(cell)
|
||||
if (mineCount==0) {
|
||||
handleEmpty(cell)
|
||||
} else {
|
||||
updateCellValue(cell, mineCount.toString())
|
||||
const dataValue = document.createAttribute('data-value')
|
||||
dataValue.value = mineCount.toString()
|
||||
cell.setAttributeNode(dataValue)
|
||||
}
|
||||
//Count and display the number of adjacent mines
|
||||
checkLevelCompletion()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Minesweeper
|
||||
32
lib/package.json
Normal file
32
lib/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "@ayo-run/mnswpr",
|
||||
"version": "0.4.31",
|
||||
"description": "Classic Minesweeper browser game",
|
||||
"author": "Ayo",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ayo-run/mnswpr"
|
||||
},
|
||||
"homepage": "https://mnswpr.com",
|
||||
"scripts": {
|
||||
"release": "bumpp && node ../scripts/release.js"
|
||||
},
|
||||
"main": "mnswpr.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./dist/mnswpr.js"
|
||||
},
|
||||
"./dist/*": {
|
||||
"default": "./dist/*"
|
||||
},
|
||||
"./*": {
|
||||
"default": "./*"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"./*",
|
||||
"./dist"
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
}
|
||||
12
lib/vite.config.js
Normal file
12
lib/vite.config.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { resolve } from 'node:path'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(import.meta.dirname, './mnswpr.js'),
|
||||
name: 'mnswpr',
|
||||
fileName: 'mnswpr'
|
||||
}
|
||||
}
|
||||
})
|
||||
39
package.json
39
package.json
|
|
@ -1,27 +1,38 @@
|
|||
{
|
||||
"name": "@ayco/mnswpr",
|
||||
"version": "0.4.1",
|
||||
"name": "monorepo",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"description": "Classic Minesweeper browser game",
|
||||
"author": "Ayo Ayco",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ayoayco/mnswpr"
|
||||
"url": "https://github.com/ayo-run/mnswpr"
|
||||
},
|
||||
"main": "src/index.js",
|
||||
"homepage": "https://mnswpr.com",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "vite",
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"firebase": "^12.11.0"
|
||||
"test": "echo \"Warn: no test specified\"",
|
||||
"dev": "vite app",
|
||||
"start": "vite app",
|
||||
"build": "vite build app",
|
||||
"build:lib": "vite build lib",
|
||||
"release:lib": "pnpm -F @ayo-run/mnswpr run release",
|
||||
"publish:lib": "cd lib && npm publish",
|
||||
"build:preview": "pnpm -F app run build:preview",
|
||||
"prepare": "husky",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/css": "^1.1.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"bumpp": "^11.0.1",
|
||||
"eslint": "^10.1.0",
|
||||
"globals": "^17.4.0",
|
||||
"husky": "^9.1.7",
|
||||
"simple-git": "^3.33.0",
|
||||
"vite": "^8.0.3"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
|
||||
}
|
||||
|
|
|
|||
875
pnpm-lock.yaml
875
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
packages:
|
||||
- "lib"
|
||||
- "app"
|
||||
29
scripts/release.js
Normal file
29
scripts/release.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// forked from https://github.com/elk-zone/elk/blob/main/scripts/release.ts
|
||||
import { simpleGit } from 'simple-git'
|
||||
|
||||
const git = simpleGit()
|
||||
const hash = await git.revparse(['main'])
|
||||
|
||||
console.log('Fetch remote gh repo')
|
||||
await git.fetch('gh')
|
||||
|
||||
console.log('Checkout release branch')
|
||||
await git.checkout(['-b', 'release', '--track', 'gh/release'])
|
||||
|
||||
console.log(`Reset to main branch (${hash})`)
|
||||
await git.reset(['--hard', hash])
|
||||
|
||||
console.log('Push to release branch')
|
||||
await git.push(['--force', 'gh'])
|
||||
|
||||
console.log('Checkout main branch')
|
||||
await git.checkout('main')
|
||||
|
||||
console.log('Deleting local release branch')
|
||||
await git.branch(['-D', 'release'])
|
||||
|
||||
// TODO: handle multiple remotes with a data structure
|
||||
console.log('Push tags')
|
||||
await git.push(['--tags'])
|
||||
await git.push(['--tags', 'gh'])
|
||||
await git.push(['--tags', 'sh'])
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
export const levels = {
|
||||
beginner: {
|
||||
rows: 9,
|
||||
cols: 9,
|
||||
mines: 10,
|
||||
name: 'beginner'
|
||||
},
|
||||
intermediate: {
|
||||
rows: 16,
|
||||
cols: 16,
|
||||
mines: 40,
|
||||
name: 'intermediate'
|
||||
},
|
||||
expert: {
|
||||
rows: 16,
|
||||
cols: 30,
|
||||
mines: 99,
|
||||
name: 'expert'
|
||||
},
|
||||
nightmare: {
|
||||
rows: 20,
|
||||
cols: 30,
|
||||
mines: 150,
|
||||
name: 'nightmare'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import './index.css';
|
||||
import './modules/loading/loading.css';
|
||||
import './assets/favicon.ico';
|
||||
import Minesweeper from './minesweeper.js';
|
||||
|
||||
const myMinesweeper = new Minesweeper('app');
|
||||
myMinesweeper.initialize();
|
||||
|
|
@ -1,893 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
import {
|
||||
LeaderBoardService,
|
||||
LoggerService,
|
||||
StorageService,
|
||||
TimerService
|
||||
} from './modules';
|
||||
import { levels } from './levels.js';
|
||||
|
||||
import * as pkg from '../package.json'
|
||||
|
||||
const TEST_MODE = false; // set to true if you want to test the game with visual hints and separate leaderboard
|
||||
const VERSION = pkg.version;
|
||||
const MOBILE_BUSY_DELAY = 250;
|
||||
const PC_BUSY_DELAY = 500;
|
||||
const CASUAL_MODE = false;
|
||||
|
||||
export const Minesweeper = function(appId) {
|
||||
const _this = this;
|
||||
const storageService = new StorageService();
|
||||
const timerService = new TimerService();
|
||||
const loggerService = new LoggerService();
|
||||
const leaderBoard = new LeaderBoardService('mw-leaders', 'mw-all', 'mw-config');
|
||||
|
||||
let grid = document.createElement('table');
|
||||
grid.setAttribute('id', 'grid');
|
||||
let flagsDisplay = document.createElement('span');
|
||||
let smileyDisplay = document.createElement('span');
|
||||
let timerDisplay = document.createElement('span');
|
||||
let footbar = document.createElement('div');
|
||||
let customWrapper = document.createElement('div');
|
||||
customWrapper.setAttribute('id', 'custom-wrapper');
|
||||
let appElement = document.getElementById(appId);
|
||||
if (!appElement) {
|
||||
const body = document.getElementsByTagName('body')[0];
|
||||
appElement = document.createElement('div');
|
||||
body.append(appElement);
|
||||
}
|
||||
let leaderWrapper = document.createElement('div');
|
||||
|
||||
let isMobile = false;
|
||||
let isLeft = false;
|
||||
let isRight = false;
|
||||
let pressed = undefined;
|
||||
let bothPressed = undefined;
|
||||
let skip = false;
|
||||
let skipCondition = false;
|
||||
let mouseUpCallBackArray = [
|
||||
clickCell,
|
||||
middleClickCell,
|
||||
];
|
||||
let mouseDownCallBackArray = [
|
||||
highlightCell, // left-click down
|
||||
highlightSurroundingCell, // middle-click down
|
||||
rightClickCell // right-click down
|
||||
]
|
||||
let firstClick = true;
|
||||
let isBusy = false;
|
||||
let clickedCell;
|
||||
let cachedSetting = storageService.getFromLocal('setting');
|
||||
let setting = cachedSetting || levels.beginner;
|
||||
if (TEST_MODE) {
|
||||
setting = {
|
||||
rows: 10,
|
||||
cols: 10,
|
||||
mines: 10,
|
||||
name: 'test'
|
||||
}
|
||||
}
|
||||
storageService.saveToLocal('setting', setting);
|
||||
let flagsCount = setting.mines;
|
||||
let minesArray = [];
|
||||
|
||||
this.initialize = function() {
|
||||
const headingElement = document.createElement('h1');
|
||||
const gameBoard = document.createElement('div');
|
||||
|
||||
headingElement.innerText = `Minesweeper v${VERSION}`;
|
||||
gameBoard.setAttribute('id', 'game-board');
|
||||
gameBoard.append(initializeToolbar(), grid, initializeFootbar());
|
||||
if(appElement) {
|
||||
appElement.innerHTML = '';
|
||||
appElement.append(headingElement, gameBoard);
|
||||
appElement.append(initializeSourceLink());
|
||||
}
|
||||
generateGrid();
|
||||
}
|
||||
|
||||
function initializeSourceLink() {
|
||||
const sourceLink = document.createElement('a');
|
||||
sourceLink.href = 'https://github.com/ayoayco/mnswpr';
|
||||
sourceLink.innerText = 'Source code';
|
||||
sourceLink.target = '_blank';
|
||||
sourceLink.style.color = 'white';
|
||||
|
||||
return sourceLink;
|
||||
}
|
||||
|
||||
function initializeLeaderBoard() {
|
||||
const title = `Best Times (${setting.name})`;
|
||||
leaderBoard.update(setting.name, leaderWrapper, title);
|
||||
|
||||
if(appElement)
|
||||
appElement.append(leaderWrapper);
|
||||
}
|
||||
|
||||
function initializeFootbar() {
|
||||
const footBar = document.createElement('div');
|
||||
|
||||
const resetButton = document.createElement('button');
|
||||
resetButton.innerText = 'Reset';
|
||||
resetButton.onmousedown = () => generateGrid();
|
||||
footBar.append(resetButton);
|
||||
|
||||
let levelsDropdown = document.createElement('select');
|
||||
levelsDropdown.onchange = () => updateSetting(levelsDropdown.value);
|
||||
|
||||
const levelsKeys = Object.keys(levels);
|
||||
levelsKeys.forEach(key => {
|
||||
const levelOption = document.createElement('option');
|
||||
levelOption.value = levels[key].name;
|
||||
levelOption.text = capitalize(levels[key].name);
|
||||
if (setting.name === levelOption.value) {
|
||||
levelOption.selected = true;
|
||||
}
|
||||
levelsDropdown.add(levelOption, null);
|
||||
});
|
||||
|
||||
// custom level
|
||||
// const customOption = document.createElement('option');
|
||||
// customOption.onmousedown = () => {}
|
||||
// customOption.value = 'custom';
|
||||
// customOption.text = 'Custom';
|
||||
// levelsDropdown.add(customOption);
|
||||
|
||||
if (TEST_MODE) {
|
||||
const testLevel = document.createElement('span');
|
||||
testLevel.innerText = 'Test Mode';
|
||||
footBar.append(testLevel);
|
||||
} else {
|
||||
footBar.append(levelsDropdown);
|
||||
}
|
||||
|
||||
return footBar;
|
||||
}
|
||||
|
||||
function removeCustomOptions() {
|
||||
const customCopy = document.getElementById('custom-wrapper');
|
||||
if (customCopy) {
|
||||
footbar.removeChild(customWrapper);
|
||||
}
|
||||
}
|
||||
|
||||
function insertCustomOptions() {
|
||||
|
||||
const inputElements = [];
|
||||
|
||||
const rowsInput = document.createElement('input');
|
||||
rowsInput.placeholder = 'Rows';
|
||||
inputElements.push(rowsInput);
|
||||
|
||||
const colsInput = document.createElement('input');
|
||||
colsInput.placeholder = 'Columns';
|
||||
inputElements.push(colsInput);
|
||||
|
||||
const bombsInput = document.createElement('input');
|
||||
bombsInput.placeholder = 'Bombs';
|
||||
inputElements.push(bombsInput);
|
||||
|
||||
const okButton = document.createElement('button');
|
||||
okButton.innerText = 'Okay';
|
||||
const setting = {rows: rowsInput.value, cols: colsInput.value, bombs: bombsInput.value};
|
||||
okButton.onmousedown = () => updateSetting('custom-action', setting);
|
||||
|
||||
inputElements.forEach(input => {
|
||||
input.style.marginRight = '15px';
|
||||
input.style.width = '100px';
|
||||
input.maxLength = 3;
|
||||
input.type = 'number';
|
||||
input.width = 50;
|
||||
});
|
||||
|
||||
customWrapper.append(...inputElements, okButton);
|
||||
footbar.append(customWrapper);
|
||||
}
|
||||
|
||||
function capitalize(str) {
|
||||
if (!str) return '';
|
||||
return `${str[0].toUpperCase()}${str.slice(1, str.length)}`;
|
||||
}
|
||||
|
||||
function initializeToolbar() {
|
||||
const toolbar = document.createElement('div');
|
||||
const toolbarItems = [];
|
||||
|
||||
const flagsWrapper = document.createElement('div');
|
||||
flagsWrapper.append(flagsDisplay)
|
||||
flagsWrapper.style.height = '20px';
|
||||
toolbar.append(flagsWrapper);
|
||||
toolbarItems.push(flagsWrapper);
|
||||
|
||||
const smileyWrapper = document.createElement('div');
|
||||
smileyWrapper.append(smileyDisplay);
|
||||
// toolbar.append(smileyWrapper);
|
||||
// toolbarItems.push(smileyWrapper);
|
||||
|
||||
const timerWrapper = document.createElement('div');
|
||||
timerWrapper.append(timerDisplay);
|
||||
timerWrapper.style.height = '20px';
|
||||
toolbar.append(timerWrapper);
|
||||
toolbarItems.push(timerWrapper);
|
||||
|
||||
toolbar.style.cursor = 'pointer';
|
||||
toolbar.style.padding = '10px 35px';
|
||||
toolbar.style.display = 'flex';
|
||||
toolbar.style.justifyContent = 'space-between';
|
||||
toolbar.onmousedown = () => generateGrid();
|
||||
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
function updateSetting(key, custom) {
|
||||
if (key === 'custom') {
|
||||
insertCustomOptions();
|
||||
} else if (key === 'custom-action') {
|
||||
console.log('custom', custom);
|
||||
} else {
|
||||
setting = levels[key];
|
||||
storageService.saveToLocal('setting', setting);
|
||||
removeCustomOptions();
|
||||
generateGrid();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function generateGrid() {
|
||||
|
||||
//generate 10 by 10 grid
|
||||
firstClick = true;
|
||||
grid.innerHTML = '';
|
||||
grid.oncontextmenu = () => false;
|
||||
flagsCount = setting.mines;
|
||||
minesArray = [];
|
||||
|
||||
for (let i = 0; i < setting.rows; i++) {
|
||||
let row = grid.insertRow(i);
|
||||
row.oncontextmenu = () => false;
|
||||
for (let j=0; j<setting.cols; j++) {
|
||||
let cell = row.insertCell(j);
|
||||
initializeEventHandlers(cell);
|
||||
|
||||
if ('ontouchstart' in document.documentElement) {
|
||||
isMobile = true;
|
||||
initializeTouchEventHandlers(cell);
|
||||
}
|
||||
|
||||
let status = document.createAttribute("data-status");
|
||||
status.value = "default";
|
||||
cell.setAttributeNode(status);
|
||||
}
|
||||
}
|
||||
|
||||
let gameStatus = document.createAttribute('game-status');
|
||||
gameStatus.value = 'inactive';
|
||||
grid.setAttributeNode(gameStatus);
|
||||
|
||||
if (appElement) {
|
||||
appElement.style.minWidth = '260px';
|
||||
appElement.style.width = `${grid.offsetWidth + 40}px`;
|
||||
appElement.style.margin = '0 auto';
|
||||
}
|
||||
|
||||
if (!CASUAL_MODE) {
|
||||
initializeLeaderBoard();
|
||||
}
|
||||
|
||||
timerService.initialize(timerDisplay);
|
||||
updateFlagsCountDisplay();
|
||||
addMines(setting.mines);
|
||||
|
||||
}
|
||||
|
||||
function setBusy() {
|
||||
isBusy = true;
|
||||
if (isMobile) {
|
||||
setTimeout(() => isBusy = false, MOBILE_BUSY_DELAY);
|
||||
} else {
|
||||
setTimeout(() => isBusy = false, PC_BUSY_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
function updateFlagsCountDisplay(count = flagsCount) {
|
||||
if (grid.getAttribute('game-status') != 'win') {
|
||||
flagsDisplay.innerHTML = `${count}`;
|
||||
return;
|
||||
}
|
||||
flagsDisplay.innerHTML = '😁';
|
||||
}
|
||||
|
||||
function initializeTouchEventHandlers(_cell) {
|
||||
let cell = document.createElement('td');
|
||||
cell = _cell;
|
||||
|
||||
let ontouchleave = function(e) {
|
||||
if (clickedCell === this) {
|
||||
clickedCell = undefined
|
||||
}
|
||||
}
|
||||
cell.addEventListener('touchleave', ontouchleave);
|
||||
|
||||
let ontouchend = function(e) {
|
||||
endTouchTimer();
|
||||
}
|
||||
cell.addEventListener('touchend', ontouchend);
|
||||
|
||||
let ontouchstart = function(e) {
|
||||
isMobile = true;
|
||||
if (!isBusy && typeof e === 'object') {
|
||||
startTouchTimer(this);
|
||||
}
|
||||
}
|
||||
cell.addEventListener('touchstart', ontouchstart);
|
||||
|
||||
}
|
||||
|
||||
function initializeEventHandlers(_cell) {
|
||||
|
||||
let cell = _cell;
|
||||
skip = false;
|
||||
skipCondition = false;
|
||||
|
||||
resetMouseEventFlags();
|
||||
|
||||
document.onkeydown = function(e) {
|
||||
if (e.keyCode == 32 || e.keyCode == 113) {
|
||||
generateGrid();
|
||||
if ('preventDefault' in e) {
|
||||
e.preventDefault();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
resetMouseEventFlags();
|
||||
}
|
||||
|
||||
window.onblur = function() {
|
||||
resetMouseEventFlags();
|
||||
}
|
||||
|
||||
grid.onmouseleave = function() {
|
||||
removeHighlights();
|
||||
}
|
||||
document.oncontextmenu = () => false;
|
||||
document.onmouseup = function() {
|
||||
resetMouseEventFlags();
|
||||
}
|
||||
document.onmousedown = function(e) {
|
||||
isMobile = false;
|
||||
switch (e.button) {
|
||||
case 0: pressed = 'left'; isLeft = true; break;
|
||||
case 1: pressed = 'middle'; break;
|
||||
case 2: isRight = true; break;
|
||||
}
|
||||
}
|
||||
|
||||
// Set grid status to active on first click
|
||||
cell.onmouseup = function(e) {
|
||||
pressed = undefined;
|
||||
let dont = false;
|
||||
|
||||
if (bothPressed) {
|
||||
bothPressed = false;
|
||||
if (e.button == '2') {
|
||||
skipCondition = true;
|
||||
} else if (e.button == '0') {
|
||||
dont = true;
|
||||
}
|
||||
if (getStatus(this) == 'clicked') {
|
||||
middleClickCell(this);
|
||||
return;
|
||||
}
|
||||
}
|
||||
switch(e.button) {
|
||||
case 0: {
|
||||
isLeft = false;
|
||||
if (skipCondition) {
|
||||
skip = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 2: isRight = false; break
|
||||
}
|
||||
removeHighlights();
|
||||
if (skip || dont) {
|
||||
skip = false;
|
||||
skipCondition = false;
|
||||
return;
|
||||
}
|
||||
if (!isBusy && typeof e === 'object' && e.button != 2) {
|
||||
mouseUpCallBackArray[e.button].call(_this, this);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
cell.onmousedown = function(e) {
|
||||
skip = false;
|
||||
if (!isBusy && typeof e === 'object') {
|
||||
switch(e.button) {
|
||||
case 0: isLeft = true; break;
|
||||
case 2: isRight = true; break
|
||||
}
|
||||
|
||||
if (isLeft && isRight) {
|
||||
bothPressed = true;
|
||||
highlightSurroundingCell(this);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.button == '1') {
|
||||
pressed = 'middle';
|
||||
highlightSurroundingCell(this);
|
||||
} else if (e.button == '0') {
|
||||
pressed = 'left';
|
||||
if (getStatus(this) == 'clicked') {
|
||||
highlightSurroundingCell(this);
|
||||
} else {
|
||||
highlightCell(this);
|
||||
}
|
||||
}
|
||||
|
||||
if (e.button == '2') mouseDownCallBackArray[e.button].call(_this, this);
|
||||
}
|
||||
}
|
||||
|
||||
cell.onmousemove = function(e) {
|
||||
if ((pressed || bothPressed) && typeof e === 'object') {
|
||||
removeHighlights();
|
||||
/*
|
||||
if (!isEqual(clickedCell, cell)) {
|
||||
clickedCell = undefined;
|
||||
}
|
||||
*/
|
||||
if (pressed == 'middle' || (isLeft && isRight)) {
|
||||
highlightSurroundingCell(this);
|
||||
} else if (pressed == 'left') {
|
||||
if (getStatus(this) == 'clicked') {
|
||||
highlightSurroundingCell(this);
|
||||
} else {
|
||||
highlightCell(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cell.oncontextmenu = () => false;
|
||||
cell.onselectstart = () => false;
|
||||
cell.setAttribute('unselectable', 'on');
|
||||
}
|
||||
|
||||
function isEqual(x, y) {
|
||||
if (!x) return false;
|
||||
return x === y;
|
||||
}
|
||||
|
||||
function startTouchTimer(cell) {
|
||||
if (isEqual(clickedCell, cell)) {
|
||||
return;
|
||||
}
|
||||
clickedCell = cell;
|
||||
setTimeout(() => {
|
||||
if (isEqual(clickedCell, cell)) {
|
||||
rightClickCell(cell);
|
||||
setBusy();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function endTouchTimer() {
|
||||
clickedCell = undefined;
|
||||
}
|
||||
|
||||
function resetMouseEventFlags() {
|
||||
pressed = undefined;
|
||||
bothPressed = undefined;
|
||||
isLeft = false;
|
||||
isRight = false;
|
||||
removeHighlights();
|
||||
skip = true;
|
||||
}
|
||||
|
||||
function addMines(minesCount) {
|
||||
//Add mines randomly
|
||||
for (let i=0; i<minesCount; i++) {
|
||||
let row = Math.floor(Math.random() * setting.rows);
|
||||
let col = Math.floor(Math.random() * setting.cols);
|
||||
let cell = grid.rows[row].cells[col];
|
||||
if (isMine(cell)) {
|
||||
transferMine();
|
||||
} else {
|
||||
minesArray.push([row, col]);
|
||||
}
|
||||
if (TEST_MODE){
|
||||
cell.innerHTML = 'X';
|
||||
}
|
||||
}
|
||||
if (TEST_MODE) {
|
||||
printMines();
|
||||
}
|
||||
}
|
||||
|
||||
function revealMines() {
|
||||
if (grid.getAttribute('game-status') == 'done') return;
|
||||
//Highlight all mines in red
|
||||
const win = grid.getAttribute('game-status') == 'win';
|
||||
for (let i=0; i<setting.rows; i++) {
|
||||
for(let j=0; j<setting.cols; j++) {
|
||||
let cell = grid.rows[i].cells[j];
|
||||
if (win) {
|
||||
handleWinRevelation(cell);
|
||||
} else {
|
||||
handleLostRevelation(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
grid.setAttribute('game-status', 'done');
|
||||
|
||||
const time = timerService.stop();
|
||||
const game = {
|
||||
time,
|
||||
status: win ? 'win' : 'loss',
|
||||
level: setting.name,
|
||||
time_stamp: new Date(),
|
||||
isMobile
|
||||
}
|
||||
|
||||
if (!TEST_MODE) {
|
||||
leaderBoard.send(game, 'time');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function handleWinRevelation(cell) {
|
||||
updateFlagsCountDisplay(0);
|
||||
if (isMine(cell)) {
|
||||
cell.innerHTML = ':)'
|
||||
cell.className = 'correct';
|
||||
setStatus(cell, 'clicked');
|
||||
let correct = document.createAttribute('title');
|
||||
correct.value = 'Correct';
|
||||
cell.setAttributeNode(correct)
|
||||
setStatus(cell, 'clicked');
|
||||
}
|
||||
}
|
||||
|
||||
function handleLostRevelation(cell) {
|
||||
if (isFlagged(cell)) {
|
||||
cell.className = 'flag'
|
||||
if (!isMine(cell)) {
|
||||
cell.innerHTML = 'X';
|
||||
cell.className = 'wrong';
|
||||
let wrong = document.createAttribute('title');
|
||||
wrong.value = 'Wrong';
|
||||
cell.setAttributeNode(wrong);
|
||||
} else {
|
||||
cell.innerHTML = ':)'
|
||||
cell.className = 'correct';
|
||||
let correct = document.createAttribute('title');
|
||||
correct.value = 'Correct';
|
||||
cell.setAttributeNode(correct);
|
||||
}
|
||||
} else {
|
||||
if (isMine(cell)) {
|
||||
cell.className = 'mine';
|
||||
setStatus(cell, 'clicked');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isOpen(cell) {
|
||||
return cell.innerHTML !== '' && !isFlagged(cell);
|
||||
}
|
||||
|
||||
function isFlagged(cell) {
|
||||
return getStatus(cell) == 'flagged';
|
||||
}
|
||||
|
||||
function isMine(cell) {
|
||||
return getIndex(minesArray, cell) > -1;
|
||||
}
|
||||
|
||||
function removeItem(arr, cell) {
|
||||
const index = getIndex(arr, cell);
|
||||
if (index > -1) {
|
||||
arr.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function getIndex(arr, cell) {
|
||||
const row = cell.parentNode.rowIndex;
|
||||
const col = cell.cellIndex;
|
||||
let index = -1;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
let rowCol = arr[i]
|
||||
if (rowCol[0] === row && rowCol[1] === col) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
function checkLevelCompletion() {
|
||||
let levelComplete = true;
|
||||
for (let i=0; i<setting.rows; i++) {
|
||||
for(let j=0; j<setting.cols; j++) {
|
||||
const cell = grid.rows[i].cells[j];
|
||||
if (!isMine(cell) && cell.innerHTML=="") levelComplete=false;
|
||||
}
|
||||
}
|
||||
if (levelComplete && grid.getAttribute('game-status') == 'active') {
|
||||
grid.setAttribute('game-status', 'win');
|
||||
revealMines();
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(cell, status) {
|
||||
cell.setAttribute('data-status', status);
|
||||
}
|
||||
|
||||
function getCol(cell) {
|
||||
return cell.cellIndex;
|
||||
}
|
||||
|
||||
function getRow(cell) {
|
||||
return cell.parentNode.rowIndex;
|
||||
}
|
||||
|
||||
function getStatus(cell) {
|
||||
if (!cell) return undefined;
|
||||
return cell.getAttribute('data-status');
|
||||
}
|
||||
|
||||
function middleClickCell(cell) {
|
||||
if (grid.getAttribute('game-status') != 'active' || getStatus(cell) !== 'clicked') {
|
||||
return;
|
||||
}
|
||||
// check for number of surrounding flags
|
||||
const valueString = cell.getAttribute('data-value');
|
||||
let cellValue = parseInt(valueString, 10);
|
||||
let flagCount = countFlagsAround(cell);
|
||||
|
||||
if (flagCount === cellValue) {
|
||||
clickSurrounding(cell);
|
||||
if (TEST_MODE) loggerService.debug('middle click', cell);
|
||||
}
|
||||
}
|
||||
|
||||
function countFlagsAround(cell) {
|
||||
let flagCount = 0;
|
||||
let cellRow = cell.parentNode.rowIndex;
|
||||
let cellCol = cell.cellIndex;
|
||||
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
|
||||
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
|
||||
if (isFlagged(grid.rows[i].cells[j])) flagCount++;
|
||||
}
|
||||
}
|
||||
return flagCount;
|
||||
}
|
||||
|
||||
function clickSurrounding(cell) {
|
||||
if (grid.getAttribute('game-status') != 'active') return;
|
||||
let cellRow = cell.parentNode.rowIndex;
|
||||
let cellCol = cell.cellIndex;
|
||||
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
|
||||
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
|
||||
let currentCell = grid.rows[i].cells[j];
|
||||
if (getStatus(currentCell) == 'flagged') continue;
|
||||
openCell(currentCell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function increaseFlagsCount() {
|
||||
flagsCount++;
|
||||
updateFlagsCountDisplay();
|
||||
}
|
||||
|
||||
function decreaseFlagsCount() {
|
||||
flagsCount--;
|
||||
updateFlagsCountDisplay();
|
||||
}
|
||||
|
||||
function activateGame() {
|
||||
grid.setAttribute('game-status', 'active');
|
||||
// start timer
|
||||
timerService.start();
|
||||
}
|
||||
|
||||
function gameIsDone() {
|
||||
return grid.getAttribute('game-status') == 'over' || grid.getAttribute('game-status') == 'done';
|
||||
}
|
||||
|
||||
function removeHighlights() {
|
||||
for (let i=0; i<setting.rows; i++) {
|
||||
const rows = grid.rows[i];
|
||||
if (!rows) continue;
|
||||
for(let j=0; j<setting.cols; j++) {
|
||||
let currentCell = grid.rows[i].cells[j];
|
||||
if (getStatus(currentCell) == 'highlighted') setStatus(currentCell, 'default');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function highlightCell(cell) {
|
||||
if (isFlagged(cell)) return;
|
||||
if (!gameIsDone() && getStatus(cell) == 'default') setStatus(cell, 'highlighted'); // currentCell.classList.add('highlight');
|
||||
}
|
||||
|
||||
function highlightSurroundingCell(cell) {
|
||||
let cellRow = cell.parentNode.rowIndex;
|
||||
let cellCol = cell.cellIndex;
|
||||
|
||||
highlightCell(cell);
|
||||
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1, setting.rows - 1); i++) {
|
||||
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1, setting.cols - 1); j++) {
|
||||
let currentCell = grid.rows[i].cells[j];
|
||||
highlightCell(currentCell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rightClickCell(cell) {
|
||||
if (isFlagged(cell)) setBusy();
|
||||
if (grid.getAttribute('game-status') == 'inactive') {
|
||||
activateGame();
|
||||
}
|
||||
if (grid.getAttribute('game-status') != 'active') return;
|
||||
if (getStatus(cell) != 'clicked' && getStatus(cell) != 'empty') {
|
||||
if (getStatus(cell) == 'default' || getStatus(cell) == 'highlighted') {
|
||||
if (flagsCount <= 0) return;
|
||||
cell.className = 'flag';
|
||||
decreaseFlagsCount();
|
||||
setStatus(cell, 'flagged');
|
||||
} else {
|
||||
cell.className = '';
|
||||
increaseFlagsCount();
|
||||
setStatus(cell, 'default');
|
||||
}
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate(100);
|
||||
}
|
||||
if (TEST_MODE) loggerService.debug('right click', cell);
|
||||
}
|
||||
}
|
||||
|
||||
function clickCell(cell) {
|
||||
if (isFlagged(cell)) setBusy();
|
||||
if (grid.getAttribute('game-status') == 'inactive') {
|
||||
activateGame();
|
||||
}
|
||||
if (grid.getAttribute('game-status') != 'active') return;
|
||||
//Check if the end-user clicked on a mine
|
||||
if (TEST_MODE) loggerService.debug('click', cell);
|
||||
if (getStatus(cell) == 'flagged' || grid.getAttribute('game-status') == 'over') {
|
||||
return;
|
||||
} else if (getStatus(cell) == 'clicked') {
|
||||
middleClickCell(cell);
|
||||
return
|
||||
} else if (isMine(cell) && firstClick) {
|
||||
// cell.setAttribute('data-mine', 'false');
|
||||
removeItem(minesArray, cell);
|
||||
transferMine(cell);
|
||||
if (TEST_MODE) printMines();
|
||||
}
|
||||
|
||||
openCell(cell);
|
||||
}
|
||||
|
||||
function printMines() {
|
||||
let count = 0;
|
||||
for (let i = 0; i < setting.rows; i++) {
|
||||
for (let j = 0; j < setting.cols; j++) {
|
||||
if (isMine(grid.rows[i].cells[j])) {
|
||||
loggerService.debug(count++ + ' - mine: [' + i + ',' + j + ']');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function transferMine(cell = undefined) {
|
||||
let found = false;
|
||||
do {
|
||||
let row = Math.floor(Math.random() * setting.rows);
|
||||
let col = Math.floor(Math.random() * setting.cols);
|
||||
const transferMineToCell = grid.rows[row].cells[col];
|
||||
if (isMine(transferMineToCell) || isNeighbor(cell, transferMineToCell)) {
|
||||
continue;
|
||||
} else {
|
||||
minesArray.push([row, col]);
|
||||
if (TEST_MODE){
|
||||
transferMineToCell.innerHTML = 'X';
|
||||
if (TEST_MODE) loggerService.debug('transferred mine to: ' + row + ', ' + col);
|
||||
}
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
} while(!found)
|
||||
}
|
||||
|
||||
function isNeighbor(cell, nextCell) {
|
||||
if (cell === undefined) {
|
||||
return;
|
||||
}
|
||||
const rowDifference = Math.abs(getRow(cell) - getRow(nextCell));
|
||||
const colDifference = Math.abs(getCol(cell) - getCol(nextCell));
|
||||
|
||||
return (rowDifference === 1) && (colDifference === 1);
|
||||
}
|
||||
|
||||
function countMinesAround(cell) {
|
||||
let mineCount=0;
|
||||
let cellRow = cell.parentNode.rowIndex;
|
||||
let cellCol = cell.cellIndex;
|
||||
for (let i = Math.max(cellRow-1,0); i <= Math.min(cellRow+1,setting.rows-1); i++) {
|
||||
const rows = grid.rows[i];
|
||||
if (!rows) continue;
|
||||
for(let j = Math.max(cellCol-1,0); j <= Math.min(cellCol+1,setting.cols-1); j++) {
|
||||
const cell = rows.cells[j];
|
||||
const mine = isMine(cell);
|
||||
if (cell && mine) {
|
||||
mineCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return mineCount;
|
||||
}
|
||||
|
||||
function updateCellValue(cell, value) {
|
||||
const spanElement = document.createElement('span');
|
||||
spanElement.innerHTML = value;
|
||||
cell.innerHTML = '';
|
||||
cell.appendChild(spanElement);
|
||||
}
|
||||
|
||||
function handleEmpty(cell) {
|
||||
updateCellValue(cell, ' ');
|
||||
let cellRow = cell.parentNode.rowIndex;
|
||||
let cellCol = cell.cellIndex;
|
||||
setStatus(cell, 'empty');
|
||||
//Reveal all adjacent cells as they do not have a mine
|
||||
for (let y = Math.max(cellRow-1,0); y <= Math.min(cellRow+1, setting.rows - 1); y++) {
|
||||
const rows = grid.rows[y];
|
||||
if (!rows) continue;
|
||||
for(let x = Math.max(cellCol-1,0); x <= Math.min(cellCol+1, setting.cols - 1); x++) {
|
||||
//Recursive Call
|
||||
const cell = rows.cells[x];
|
||||
if (cell && !isOpen(cell)) {
|
||||
clickCell(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openCell(cell) {
|
||||
if (grid.getAttribute('game-status') != 'active') return;
|
||||
|
||||
cell.className="clicked";
|
||||
setStatus(cell, 'clicked');
|
||||
firstClick = false;
|
||||
|
||||
if (isMine(cell)) {
|
||||
revealMines();
|
||||
flagsDisplay.innerHTML = '😱';
|
||||
grid.setAttribute('game-status', 'over');
|
||||
} else {
|
||||
const mineCount = countMinesAround(cell);
|
||||
if (mineCount==0) {
|
||||
handleEmpty(cell);
|
||||
} else {
|
||||
updateCellValue(cell, mineCount.toString());
|
||||
const dataValue = document.createAttribute('data-value');
|
||||
dataValue.value = mineCount.toString();
|
||||
cell.setAttributeNode(dataValue);
|
||||
}
|
||||
//Count and display the number of adjacent mines
|
||||
checkLevelCompletion();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Minesweeper
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
export const DialogService = function() {
|
||||
let isOpen = false;
|
||||
let isInitialized = false;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'dialog-wrapper';
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'dialog-container';
|
||||
|
||||
this.initialize = function() {
|
||||
const bodyElement = document.getElementsByTagName('body')[0];
|
||||
wrapper.appendChild(container);
|
||||
bodyElement.appendChild(wrapper);
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
this.promptMessage = function(message) {
|
||||
isOpen = true;
|
||||
}
|
||||
|
||||
this.closeDialog = function() {
|
||||
if (isOpen) {
|
||||
}
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
this.isInitialized = function() {
|
||||
return isInitialized;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
export * from './dialog/dialog.js';
|
||||
export * from './leader-board/leader-board.js';
|
||||
export * from './loading/loading.js';
|
||||
export * from './logger/logger.js';
|
||||
export * from './storage/storage.js';
|
||||
export * from './timer/timer.js';
|
||||
export * from './user/user.js';
|
||||
export * from './grid/grid.js';
|
||||
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
import { TimerService } from '../timer/timer';
|
||||
import { UserService } from '../user/user';
|
||||
import { LoadingService } from '../loading/loading';
|
||||
import { LoggerService } from '../logger/logger';
|
||||
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getFirestore, doc, getDocs, getDoc, setDoc, collection, query, orderBy, limit } from 'firebase/firestore/lite';
|
||||
|
||||
|
||||
export class LeaderBoardService {
|
||||
|
||||
timerService = new TimerService();
|
||||
loadingService = new LoadingService();
|
||||
loggerService = new LoggerService();
|
||||
user = new UserService();
|
||||
previousLevel;
|
||||
|
||||
/**
|
||||
*
|
||||
* Create the Leader Board service
|
||||
* @param {String} leaders
|
||||
* @param {String} all
|
||||
* @param {String} configuration
|
||||
*/
|
||||
constructor() {
|
||||
|
||||
// necessary keys to interact with firebase
|
||||
// not a secret
|
||||
// https://stackoverflow.com/questions/37482366/is-it-safe-to-expose-firebase-apikey-to-the-public/37484053#37484053
|
||||
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
|
||||
const config = {
|
||||
apiKey: "AIzaSyCTi_5Sm5dHFNf0d_Gn0MNWmlGheFBf6MQ",
|
||||
authDomain: "moment-188701.firebaseapp.com",
|
||||
databaseURL: "https://moment-188701.firebaseio.com",
|
||||
projectId: "secure-moment-188701",
|
||||
storageBucket: "secure-moment-188701.firebasestorage.app",
|
||||
messagingSenderId: "113827947104",
|
||||
appId: "1:113827947104:web:b176f746d8358302c51905",
|
||||
measurementId: "G-LZRDY0TG46"
|
||||
};
|
||||
const app = initializeApp(config);
|
||||
this._store = getFirestore(app);
|
||||
|
||||
const configRef = doc(this.store, 'mw-config', 'configuration')
|
||||
getDoc(configRef)
|
||||
.then(res => {
|
||||
this.configuration = res.data()
|
||||
})
|
||||
}
|
||||
|
||||
get store() {
|
||||
return this._store;
|
||||
}
|
||||
|
||||
async update(level, displayElement, title) {
|
||||
|
||||
if (level !== this.previousLevel) {
|
||||
this.loadingService.addLoading(displayElement);
|
||||
this.previousLevel = level;
|
||||
this.lastPlace = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
const q = query(
|
||||
collection(this.store, "mw-leaders", level, 'games'),
|
||||
orderBy('time'),
|
||||
limit(10)
|
||||
);
|
||||
this.topListSnapshot = await getDocs(q)
|
||||
this.renderList(displayElement, title, this.topListSnapshot.docs)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
renderList(displayElement, title, docs) {
|
||||
if (!displayElement) return;
|
||||
|
||||
displayElement.innerHTML = '';
|
||||
const leaderHeading = document.createElement('h3');
|
||||
leaderHeading.innerText = title;
|
||||
leaderHeading.style.borderBottom = '1px solid #c0c0c0';
|
||||
leaderHeading.style.paddingBottom = '10px';
|
||||
|
||||
|
||||
displayElement.style.maxWidth = '270px';
|
||||
displayElement.style.margin = '0 auto';
|
||||
|
||||
const leaderList = document.createElement('div');
|
||||
|
||||
leaderList.innerHTML = '';
|
||||
leaderList.style.listStyle = 'none';
|
||||
leaderList.style.textAlign = 'left';
|
||||
leaderList.style.marginTop = '-15px';
|
||||
|
||||
if (docs && docs.length) {
|
||||
let i = 1;
|
||||
docs.forEach(game => {
|
||||
if (game) {
|
||||
const prettyTime = this.timerService.pretty(game.data().time);
|
||||
const name = game.data().name || 'Anonymous';
|
||||
const item = document.createElement('div');
|
||||
item.style.display = 'flex';
|
||||
const nameElement =document.createElement('div');
|
||||
nameElement.innerHTML = name;
|
||||
nameElement.setAttribute('title', name);
|
||||
nameElement.style.textOverflow = 'ellipsis';
|
||||
nameElement.style.whiteSpace = 'nowrap';
|
||||
nameElement.style.overflow = 'hidden';
|
||||
nameElement.style.padding = '0 5px';
|
||||
nameElement.style.cursor = 'pointer';
|
||||
nameElement.style.fontWeight = 'bold';
|
||||
nameElement.style.fontStyle = 'italic';
|
||||
// nameElement.onmousedown = () => console.log(game.data());
|
||||
|
||||
const indexElement = document.createElement('div');
|
||||
indexElement.innerText = `#${i++}`;
|
||||
|
||||
const timeElement = document.createElement('div');
|
||||
timeElement.innerText = prettyTime;
|
||||
|
||||
item.append(indexElement, nameElement, timeElement);
|
||||
leaderList.append(item);
|
||||
}
|
||||
})
|
||||
if (docs.length >= 10) {
|
||||
this.lastPlace = docs[9].data().time;
|
||||
}
|
||||
|
||||
displayElement.append(leaderHeading, leaderList);
|
||||
} else {
|
||||
const message = document.createElement('em');
|
||||
message.innerText = 'Be the first to the top!';
|
||||
displayElement.append(leaderHeading, message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async send(game, key) {
|
||||
const sessionId = new Date().toDateString().replace(/\s/g, '_');
|
||||
const gameId = new Date().toTimeString().replace(/\s/g, '_');
|
||||
const data = {};
|
||||
data[gameId] = game;
|
||||
|
||||
const sessionRef = doc(this.store, 'mw-all', this.user.browserId, 'games', sessionId)
|
||||
await setDoc(sessionRef, data, {merge: true})
|
||||
|
||||
if (this.configuration && game.status === this.configuration.passingStatus && game[key] < this.lastPlace) {
|
||||
let name = window.prompt(this.configuration.message);
|
||||
if (!name) {
|
||||
name = 'Anonymous';
|
||||
}
|
||||
|
||||
const newGame = {
|
||||
name,
|
||||
browserId: this.user.browserId,
|
||||
...game
|
||||
}
|
||||
|
||||
const gameScoreRef = doc(collection(this.store, 'mw-leaders', game.level, 'games'))
|
||||
await setDoc(gameScoreRef, newGame)
|
||||
}
|
||||
}
|
||||
|
||||
configurationPromt() {
|
||||
if (!this.configuration) {
|
||||
loggerService.debug('Failed to fetch server configuration. Please contact your developer.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
|
||||
|
||||
|
||||
updateTimeStampsLeaders() {
|
||||
const levels = ['beginner', 'intermediate', 'expert'];
|
||||
|
||||
levels.forEach(level => {
|
||||
const collection = this.leaders.doc(level).collection('games');
|
||||
collection.get()
|
||||
.then(res => {
|
||||
const levelArray = res.docs.map(doc => ({id: doc.id, ...doc.data()}))
|
||||
// console.log(level+": ", levelArray);
|
||||
|
||||
levelArray.forEach(leaderGame => {
|
||||
// const leaderGame = levelArray[0];
|
||||
const leaderTime = leaderGame.time;
|
||||
const browser = leaderGame.browserId;
|
||||
this.all.doc(browser).collection('games')
|
||||
.get().then(games => {
|
||||
const allGames = games.docs.map(doc => ({id: doc.id, games: {...doc.data()}}));
|
||||
console.log(level + '...........' + browser);
|
||||
allGames.forEach(day => {
|
||||
const keys = Object.keys(day.games);
|
||||
const winningKeys = keys.filter(key => day.games[key].status === 'win');
|
||||
winningKeys.forEach(key => {
|
||||
const game = day.games[key];
|
||||
const dateString = [day.id, key].join(' ').replace(/_/g, ' ');
|
||||
const newGame = {time_stamp: new Date(dateString), ...leaderGame};
|
||||
if (game.time === leaderTime) {
|
||||
console.log('updated', newGame);
|
||||
// collection.doc(leaderGame.id).get().then(res => console.log(res.data()));
|
||||
collection.doc(leaderGame.id).set(newGame);
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
})
|
||||
});
|
||||
})
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
export class LoadingService {
|
||||
addLoading(element) {
|
||||
element.innerHTML = '<div class="lds-ellipsis"><div></div><div></div><div></div><div></div></div>';
|
||||
}
|
||||
removeLoading(element) {
|
||||
element.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
export class LoggerService {
|
||||
debug(message, data) {
|
||||
if (typeof message === 'string') {
|
||||
if (data) {
|
||||
console.log(message, data);
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
} else {
|
||||
console.warn(`LoggerService.debug expects a string as first parameter but got a ${typeof message}`, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
export class StorageService {
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
saveToLocal(key, value) {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
saveToSession(key, value) {
|
||||
sessionStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
getFromLocal(key) {
|
||||
const data = localStorage.getItem(key);
|
||||
if (data !== 'undefined') return JSON.parse(data);
|
||||
}
|
||||
|
||||
getFromSession(key) {
|
||||
const data = sessionStorage.getItem(key);
|
||||
if (data !== 'undefined') return JSON.parse(data);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import { LoggerService } from "../logger/logger";
|
||||
|
||||
const INTERVAL = 1;
|
||||
|
||||
export class TimerService {
|
||||
|
||||
constructor() {
|
||||
this.loggerService = new LoggerService();
|
||||
}
|
||||
|
||||
initialize(el) {
|
||||
if (!el) return;
|
||||
|
||||
this.display = el;
|
||||
this.startTime = undefined;
|
||||
if (this.id) {
|
||||
this.stop()
|
||||
}
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.running || !this.display) return;
|
||||
|
||||
this.running = true;
|
||||
this.startTime = new Date().getTime();
|
||||
this.id = window.setInterval(() => this.updateDisplay(), INTERVAL);
|
||||
this.loggerService.debug(`started timer id: ${this.id}`);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false;
|
||||
clearInterval(this.id);
|
||||
this.loggerService.debug(`stopped timer id: ${this.id}`);
|
||||
this.id = undefined;
|
||||
return this.time;
|
||||
}
|
||||
|
||||
updateDisplay() {
|
||||
let currentTime = new Date().getTime() - this.startTime;
|
||||
this.time = Math.floor(currentTime / INTERVAL);
|
||||
this.display.innerHTML = this.pretty(this.time) || '0';
|
||||
}
|
||||
|
||||
pretty(duration) {
|
||||
if (!duration) return undefined;
|
||||
var milliseconds = parseInt((duration % 1000) / 100),
|
||||
seconds = Math.floor((duration / 1000) % 60),
|
||||
minutes = Math.floor((duration / (1000 * 60)) % 60),
|
||||
hours = Math.floor((duration / (1000 * 60 * 60)) % 24);
|
||||
|
||||
hours = (hours < 10) ? `0${hours}` : hours;
|
||||
minutes = (minutes < 10) ? `0${minutes}` : minutes;
|
||||
seconds = (seconds < 10) ? `0${seconds}` : seconds;
|
||||
|
||||
return `${this.clean(hours, ':')}${this.clean(minutes, ':')}${this.clean(seconds, '.')}${this.clean(milliseconds, '')}`;
|
||||
}
|
||||
|
||||
clean(str, separator) {
|
||||
return (str === '00') ? '' : `${str}${separator}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
export class UserService {
|
||||
constructor() {
|
||||
if (!this.id) {
|
||||
this.browserId = this.generateId();
|
||||
}
|
||||
}
|
||||
|
||||
generateId() {
|
||||
|
||||
var nav = window.navigator;
|
||||
var screen = window.screen;
|
||||
var guid = nav.mimeTypes.length;
|
||||
guid += nav.userAgent.replace(/\D+/g, '');
|
||||
guid += nav.plugins.length;
|
||||
guid += screen.height || '';
|
||||
guid += screen.width || '';
|
||||
guid += screen.pixelDepth || '';
|
||||
|
||||
return guid;
|
||||
}
|
||||
}
|
||||
5
utils/index.js
Normal file
5
utils/index.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from './logger/logger.js'
|
||||
export * from './storage/storage.js'
|
||||
export * from './timer/timer.js'
|
||||
export * from './loading/loading.js'
|
||||
|
||||
13
utils/loading/loading.js
Normal file
13
utils/loading/loading.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* import styles for vite bundling
|
||||
*/
|
||||
import './loading.css'
|
||||
|
||||
export class LoadingService {
|
||||
addLoading(element) {
|
||||
element.innerHTML = '<div class="lds-ellipsis"><div></div><div></div><div></div><div></div></div>'
|
||||
}
|
||||
removeLoading(element) {
|
||||
element.innerHTML = ''
|
||||
}
|
||||
}
|
||||
13
utils/logger/logger.js
Normal file
13
utils/logger/logger.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export class LoggerService {
|
||||
debug(message, data) {
|
||||
if (typeof message === 'string') {
|
||||
if (data) {
|
||||
console.log(message, data)
|
||||
} else {
|
||||
console.log(message)
|
||||
}
|
||||
} else {
|
||||
console.warn(`LoggerService.debug expects a string as first parameter but got a ${typeof message}`, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
23
utils/storage/storage.js
Normal file
23
utils/storage/storage.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export class StorageService {
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
saveToLocal(key, value) {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
}
|
||||
|
||||
saveToSession(key, value) {
|
||||
sessionStorage.setItem(key, JSON.stringify(value))
|
||||
}
|
||||
|
||||
getFromLocal(key) {
|
||||
const data = localStorage.getItem(key)
|
||||
if (data !== 'undefined') return JSON.parse(data)
|
||||
}
|
||||
|
||||
getFromSession(key) {
|
||||
const data = sessionStorage.getItem(key)
|
||||
if (data !== 'undefined') return JSON.parse(data)
|
||||
}
|
||||
}
|
||||
63
utils/timer/timer.js
Normal file
63
utils/timer/timer.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { LoggerService } from '../logger/logger'
|
||||
|
||||
const INTERVAL = 1
|
||||
|
||||
export class TimerService {
|
||||
|
||||
constructor() {
|
||||
this.loggerService = new LoggerService()
|
||||
}
|
||||
|
||||
initialize(el) {
|
||||
if (!el) return
|
||||
|
||||
this.display = el
|
||||
this.startTime = undefined
|
||||
if (this.id) {
|
||||
this.stop()
|
||||
}
|
||||
this.updateDisplay()
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.running || !this.display) return
|
||||
|
||||
this.running = true
|
||||
this.startTime = new Date().getTime()
|
||||
this.id = window.setInterval(() => this.updateDisplay(), INTERVAL)
|
||||
this.loggerService.debug(`started timer id: ${this.id}`)
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false
|
||||
clearInterval(this.id)
|
||||
this.loggerService.debug(`stopped timer id: ${this.id}`)
|
||||
this.id = undefined
|
||||
return this.time
|
||||
}
|
||||
|
||||
updateDisplay() {
|
||||
let currentTime = new Date().getTime() - this.startTime
|
||||
this.time = Math.floor(currentTime / INTERVAL)
|
||||
this.display.innerHTML = this.pretty(this.time) || '0'
|
||||
}
|
||||
|
||||
pretty(duration) {
|
||||
if (!duration) return undefined
|
||||
var milliseconds = parseInt((duration % 1000) / 100),
|
||||
seconds = Math.floor((duration / 1000) % 60),
|
||||
minutes = Math.floor((duration / (1000 * 60)) % 60),
|
||||
hours = Math.floor((duration / (1000 * 60 * 60)) % 24)
|
||||
|
||||
hours = (hours < 10) ? `0${hours}` : hours
|
||||
minutes = (minutes < 10) ? `0${minutes}` : minutes
|
||||
seconds = (seconds < 10) ? `0${seconds}` : seconds
|
||||
|
||||
return `${this.clean(hours, ':')}${this.clean(minutes, ':')}${this.clean(seconds, '.')}${this.clean(milliseconds, '')}`
|
||||
}
|
||||
|
||||
clean(str, separator) {
|
||||
return (str === '00') ? '' : `${str}${separator}`
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in a new issue