Compare commits

...

123 commits
v0.4.2 ... main

Author SHA1 Message Date
Ayo
832afc1128 chore: add vite build app monorepo script 2026-04-05 15:31:10 +02:00
Ayo
4f3897bfaf chore(lib): move vite config to lib dir 2026-04-04 01:22:04 +02:00
Ayo
d4228c60f3 chore: update publish scripts 2026-04-03 23:46:24 +02:00
Ayo
7b9529bddb chore: add publish:lib script 2026-04-03 23:35:22 +02:00
Ayo
ba91f1ec50 chore: remove publish in release script 2026-04-03 23:34:34 +02:00
Ayo
debc9495ae chore: release v0.4.31
Some checks failed
Release / release (push) Has been cancelled
2026-04-03 23:33:25 +02:00
Ayo
f4763e711c chore: move lib publish script 2026-04-03 23:33:13 +02:00
Ayo
788fc2dfe1 chore: add publish script 2026-04-03 23:31:43 +02:00
Ayo
07ebca0834 chore: release v0.4.30
Some checks failed
Release / release (push) Has been cancelled
2026-04-03 23:31:02 +02:00
Ayo
52e223f2dd chore: add package homepage 2026-04-03 23:30:45 +02:00
Ayo
60aee72af3 chore: release v0.4.29
Some checks failed
Release / release (push) Has been cancelled
2026-04-03 23:27:04 +02:00
Ayo
3e524968f3 chore(lib): update release script 2026-04-03 23:22:11 +02:00
Ayo
af22f0908a chore: release v0.4.28
Some checks failed
Release / release (push) Has been cancelled
2026-04-03 23:20:37 +02:00
Ayo
1ae0e8b169 chore: adjust lib scripts for build and release 2026-04-03 23:19:40 +02:00
Ayo
64ac0bbebb chore(lib): add minimal-mnswpr link to guide 2026-04-03 23:19:40 +02:00
Ayo
2328a7251a chore(lib): update user guide 2026-04-03 23:19:33 +02:00
Ayo
b56530bf92 feat: New guide to building a browser game using **mnswpr** 2026-04-03 23:18:23 +02:00
Ayo
011f285bf3 chore: update publish scripts 2026-04-03 23:12:20 +02:00
Ayo
2bf6cbe968 feat(app): top links to npm and github 2026-04-03 23:11:28 +02:00
Ayo
1075422a1d refactor: use options object as param for generateGrid 2026-04-03 23:11:28 +02:00
Ayo
5a682640c6 refactor(app): initialize loadingService once 2026-04-03 23:11:28 +02:00
Ayo
1a6f07e84f chore: initial pkg license & readme 2026-04-03 23:11:28 +02:00
Ayo
6a3ce486df chore: add publish script 2026-04-03 23:09:38 +02:00
Ayo
32bb31ecf1 chore: add publish script 2026-04-03 23:09:38 +02:00
Ayo
6b01a0272e chore(app): move build & preview scripts to app workspace 2026-04-03 23:09:38 +02:00
Ayo
0555154ea6 chore: update workspace scripts 2026-04-03 15:31:20 +02:00
Ayo
b6cad231c3 chore: release v0.4.27
Some checks are pending
Release / release (push) Waiting to run
2026-04-03 15:29:37 +02:00
Ayo
c09181f380 feat(app): show leaderboard & enable sending result on game done 2026-04-03 14:57:51 +02:00
Ayo
8ba28261f8 chore: update readme to mention pnpm in tooling section 2026-04-03 14:15:20 +02:00
Ayo
93fe1d64fd chore: update readme for development setup 2026-04-03 14:14:15 +02:00
Ayo
dab366496e chore: organize pnpm workspaces 2026-04-03 14:13:11 +02:00
Ayo
45507df720 chore: update readme with more info 2026-04-03 12:20:11 +02:00
Ayo
e12115792d feat(app): update default html title 2026-04-03 12:03:21 +02:00
Ayo
cc50d990eb chore: update package repo 2026-04-03 12:03:05 +02:00
Ayo
e4f578b3da chore(app): remove unused instructions file 2026-04-03 11:32:59 +02:00
Ayo
a67cec1bd5 chore: remove unused pnpm-workspace file 2026-04-03 11:32:03 +02:00
Ayo
6e37fcfbed refactor: separate utils directory 2026-04-03 11:31:26 +02:00
Ayo
1ed1f15d36 chore: remove unnecessary vite config prop 2026-04-03 11:27:59 +02:00
Ayo
994e0d2d5b feat: set default hooks functions 2026-04-03 11:26:48 +02:00
Ayo
39683946a7 feat: expose hooks for events levelChanged & gameDone 2026-04-03 11:23:55 +02:00
Ayo
29bcaace1e refactor: separate app & lib directories 2026-04-03 11:15:18 +02:00
Ayo
8824f9215b chore: site build & preview scripts 2026-04-03 10:54:19 +02:00
Ayo
d117fb2d21 chore: move mnswpr package source to lib dir 2026-04-03 10:45:07 +02:00
Ayo
a72421defe refactor: import mnswpr.css in user main.js 2026-04-03 03:38:12 +02:00
Ayo
2fbb7f69a8 chore: release v0.4.26
Some checks failed
Release / release (push) Has been cancelled
2026-04-03 03:31:04 +02:00
Ayo
20c98e77a4 fix: export all inside dist dir 2026-04-03 03:30:56 +02:00
Ayo
ea9effe3ba chore: release v0.4.25
Some checks failed
Release / release (push) Has been cancelled
2026-04-03 03:22:40 +02:00
Ayo
902c3073ec fix: export only class as defaul 2026-04-03 03:22:27 +02:00
Ayo
2db1129489 chore: update release action to not publish to npm 2026-04-03 03:18:06 +02:00
Ayo
823b035d70 chore: release v0.4.24 2026-04-03 03:17:39 +02:00
Ayo
18187b69b0 chore: package entry as mnswpr.js 2026-04-03 03:17:27 +02:00
Ayo
a6aae78396 chore: remove unused dep esbuild 2026-04-03 03:17:05 +02:00
Ayo
79b9b7b9eb chore: release v0.4.23 2026-04-03 03:10:01 +02:00
Ayo
f9e64cf234 chore: release v0.4.22 2026-04-03 03:09:31 +02:00
Ayo
477a10238a chore: use pnpm in release gh action 2026-04-03 03:09:13 +02:00
Ayo
dc544e61e1 chore: release v0.4.21
Some checks failed
Release / release (push) Has been cancelled
2026-04-03 03:07:43 +02:00
Ayo
992c326c19 chore: release v0.4.20
Some checks failed
Release / release (push) Has been cancelled
2026-04-03 03:07:00 +02:00
Ayo
6cd5bd48f5 chore: update release gh action 2026-04-03 03:06:51 +02:00
Ayo
ccdd995e18 chore: release v0.4.19
Some checks failed
Release / release (push) Has been cancelled
2026-04-03 03:03:23 +02:00
Ayo
bb1cf4b3c2 chore: update release gh action 2026-04-03 03:03:08 +02:00
Ayo
e6c41e34af chore: release v0.4.18
Some checks failed
Release / release (push) Has been cancelled
2026-04-03 03:01:34 +02:00
Ayo
cbd7635759 chore: release v0.4.17
Some checks failed
Release / release (push) Has been cancelled
2026-04-03 03:00:28 +02:00
Ayo
d8d6533dae chore: update release gh action 2026-04-03 03:00:20 +02:00
Ayo
afa35ee79e chore: release v0.4.16
Some checks failed
Release / release (push) Has been cancelled
2026-04-03 02:54:42 +02:00
Ayo
aa8a069afb chore: release v0.4.15
Some checks failed
Release / release (push) Has been cancelled
2026-04-03 02:53:21 +02:00
Ayo
f66f8e6d52 chore: catch up package version 2026-04-03 02:52:17 +02:00
Ayo
709868ebf4 chore: release v0.0.7
Some checks failed
Release / release (push) Has been cancelled
2026-04-03 02:50:23 +02:00
Ayo
04e21c35c7 chore: enable npm publish on release ci 2026-04-03 02:50:12 +02:00
Ayo
ee9affabaa refactor: move mnswpr.css import into mnswpr.js 2026-04-03 02:46:52 +02:00
Ayo
86c46dc430 0.0.6
Some checks failed
Release / release (push) Has been cancelled
2026-04-03 02:37:50 +02:00
Ayo
d101b6c9a7 chore: add exports to package.json 2026-04-03 02:37:41 +02:00
Ayo
8b8d3ea2a0 chore: disable pnpm & npm publish on ci 2026-04-03 02:34:28 +02:00
Ayo
d4decdf21f chore: update gh action release 2026-04-03 02:33:24 +02:00
Ayo
b36f1d4bc0 chore: release v0.0.5
Some checks failed
Release / release (push) Has been cancelled
2026-04-03 02:17:48 +02:00
Ayo
bcf34d68e4 chore: indicate pnpm version in release gh action 2026-04-03 02:17:41 +02:00
Ayo
40022df835 chore: release v0.0.4
Some checks failed
Release / release (push) Has been cancelled
2026-04-03 02:14:56 +02:00
Ayo
f480c808f3 chore: update gh action to set up pnpm 2026-04-03 02:14:44 +02:00
Ayo
9f9dbf7506 chore: release v0.0.3
Some checks failed
Release / release (push) Has been cancelled
2026-04-03 02:11:54 +02:00
Ayo
2d3c6943e0 chore: use pnpm in gh action 2026-04-03 02:11:37 +02:00
Ayo
b87b324ddd chore: release v0.0.2
Some checks failed
Release / release (push) Has been cancelled
2026-04-03 02:06:54 +02:00
Ayo
516f2f2f8c chore: make no test as warning 2026-04-03 02:06:39 +02:00
Ayo
51af77d61d chore: add npm publish in github action 2026-04-03 02:05:52 +02:00
Ayo
304557163b feat: initial npm package @ayo-run/mnswpr 2026-04-03 02:02:50 +02:00
Ayo
aea4ac2518 chore: update readme w/ dev server info 2026-04-02 22:13:29 +02:00
Ayo
19bd063c24 refactor: remove unnecessary asset import 2026-04-02 22:10:07 +02:00
Ayo
8f5373b2a7 chore: release v0.4.14
Some checks failed
Release / release (push) Has been cancelled
2026-04-02 21:43:24 +02:00
Ayo
964268c311 fix: initialize the leaderboard only on initial generateGrid or updating the level 2026-04-02 21:43:15 +02:00
Ayo
82b74dcbe2 chore: release v0.4.13
Some checks failed
Release / release (push) Has been cancelled
2026-04-02 21:31:04 +02:00
Ayo
c5e4c6d27a fix: prevent the reset button from clearing the leader board 2026-04-02 21:30:54 +02:00
Ayo
2dfa43f157 chore: release v0.4.12
Some checks failed
Release / release (push) Has been cancelled
2026-04-02 21:22:19 +02:00
Ayo
ca431cfeea feat: show [version] on tab title; show [dev] as version on development mode 2026-04-02 21:21:05 +02:00
Ayo
36ac8fb474 refactor: remove unused custom level logic 2026-04-02 20:43:59 +02:00
Ayo
6abd2d6c81 fix: replace existing leaderboard when switching levels 2026-04-02 20:40:55 +02:00
Ayo
45f416433b refactor: handle loading in mnswpr class 2026-04-02 20:31:59 +02:00
Ayo
32f6976239 chore: release v0.4.11
Some checks failed
Release / release (push) Has been cancelled
2026-04-02 19:45:12 +02:00
Ayo
c5cb83abca chore: add .nvmrc 2026-04-02 19:44:56 +02:00
Ayo
de6644993e chore: release v0.4.10
Some checks failed
Release / release (push) Has been cancelled
2026-04-02 19:44:01 +02:00
Ayo
960999a803 chore: add release github action 2026-04-02 19:42:50 +02:00
Ayo
4520129f9f chore: add more stylistic rules & run formatter 2026-04-02 19:39:18 +02:00
Ayo
bdec497bc1 chore: use js for the release script 2026-04-02 19:03:10 +02:00
Ayo
04bfc22330 chore: rename to mnswpr.js 2026-04-02 18:57:13 +02:00
Ayo
4b112dcee9 chore: release v0.4.9 2026-04-02 18:50:47 +02:00
Ayo
cada410f93 fix: use setting name for leaderboard update if id is undefined 2026-04-02 18:50:37 +02:00
Ayo
1e9752eeaf chore: release v0.4.8 2026-04-02 18:48:03 +02:00
Ayo
68aa23ac24 feat: new level names - Noobs, Normies, Torment, and Hell 2026-04-02 18:47:54 +02:00
Ayo
1ee091c96f chore: release v0.4.7 2026-04-02 18:11:24 +02:00
Ayo
850df645bd chore: update release 2026-04-02 18:10:54 +02:00
Ayo
7d6dc6e484 chore: release v0.4.6 2026-04-02 18:04:55 +02:00
Ayo
1938bdd8d2 feat: remove width limits to app title 2026-04-02 18:04:45 +02:00
Ayo
51ad2ca9c5 chore: release v0.4.5 2026-04-02 18:03:09 +02:00
Ayo
7711ae0ebb chore: update release script 2026-04-02 18:02:02 +02:00
Ayo
7d7bd8b55b chore: update release script 2026-04-02 17:59:07 +02:00
Ayo
225c1b515a chore: update release script 2026-04-02 17:50:59 +02:00
Ayo
c9f599f878 chore: release v0.4.4 2026-04-02 17:32:29 +02:00
Ayo
a41604e3b2 chore: new release script inspired by how we do it on Elk.zone 2026-04-02 17:32:16 +02:00
Ayo
e3d9337438 0.4.3 2026-04-02 17:16:41 +02:00
Ayo
877331e8a4 style: app title text sizes 2026-04-02 17:16:34 +02:00
Ayo
504e42960c chore: update readme 2026-04-02 17:10:49 +02:00
Ayo
139d85001c chore: use eslint for css linting 2026-04-02 16:52:24 +02:00
Ayo
e2cac27a25 refactor: split page & game css 2026-04-02 16:46:07 +02:00
Ayo
554cb26ad2 chore: use eslint for linting & formatting 2026-04-02 14:41:52 +02:00
Ayo
3610e50426 fix: type errors 2026-04-02 14:03:30 +02:00
Ayo
a6b7e1194c chore: update readme to mention Vite 2026-04-02 12:48:24 +02:00
47 changed files with 2518 additions and 1566 deletions

26
.github/workflows/release.yml vendored Normal file
View 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
View file

@ -1,4 +1,5 @@
node_modules/
dist/
*.*~
*.*swp

2
.husky/pre-commit Normal file
View file

@ -0,0 +1,2 @@
echo "pre-commit..."
npm run lint

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
lts/*

6
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,6 @@
{
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
}

View file

@ -1,15 +1,39 @@
# Play Minesweeper Online
# Play Minesweeper Online for Free
[![Netlify Status](https://api.netlify.com/api/v1/badges/172478bd-afc5-4e47-95ba-d9ab814248fb/deploy-status)](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,10 +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`
## 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
View 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>
&middot;
<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
View 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
View 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()

View 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
View 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
View 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"
}

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

2
app/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite/types/importMeta.d.ts" />

53
eslint.config.js Normal file
View 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'])
])

View file

@ -1,29 +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="shortcut icon" type="image/png" href="/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>

View file

@ -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
View 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
View 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
View 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'
}
}

View file

@ -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
View 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 = '&#128513;'
}
/**
*
* @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 = '&#128561;'
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
View 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
View 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'
}
}
})

View file

@ -1,29 +1,38 @@
{
"name": "@ayco/mnswpr",
"version": "0.4.2",
"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",
"preview": "vite preview",
"build:preview": "npm run build && npm run preview",
"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"
}

File diff suppressed because it is too large Load diff

3
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,3 @@
packages:
- "lib"
- "app"

29
scripts/release.js Normal file
View 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'])

View file

@ -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'
}
}

View file

@ -1,7 +0,0 @@
import './index.css';
import './modules/loading/loading.css';
import '../public/favicon.ico';
import Minesweeper from './minesweeper.js';
const myMinesweeper = new Minesweeper('app');
myMinesweeper.initialize();

View file

@ -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 = '&#128513;';
}
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 = '&#128561;';
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

View file

@ -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;
}
}

View file

@ -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';

View file

@ -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.');
}
}
}

View file

@ -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);
}
})
});
});
})
});
})
}

View file

@ -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 = '';
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View file

@ -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}`;
}
}

View file

@ -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
View 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
View 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
View 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
View 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
View 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}`
}
}