Compare commits
16 commits
feature/dr
...
master
Author | SHA1 | Date | |
---|---|---|---|
9969561af1 | |||
2f6e6e97ca | |||
d093199cd9 | |||
83b6f8f188 | |||
9ce4dd67f5 | |||
70fe698c22 | |||
7156fe23e5 | |||
a73828fc86 | |||
29ab978612 | |||
87c0a69cc2 | |||
412bb56b32 | |||
bc04fdbadb | |||
55fad9db70 | |||
77f146625c | |||
eade73f9f5 | |||
e14f6679fb |
32
.drone.yml
|
@ -1,3 +1,4 @@
|
||||||
|
---
|
||||||
kind: pipeline
|
kind: pipeline
|
||||||
name: default
|
name: default
|
||||||
|
|
||||||
|
@ -14,11 +15,19 @@ steps:
|
||||||
|
|
||||||
- name: dependencies
|
- name: dependencies
|
||||||
image: node
|
image: node
|
||||||
|
failure: ignore
|
||||||
commands:
|
commands:
|
||||||
- yarn
|
- yarn
|
||||||
depends_on:
|
depends_on:
|
||||||
- restore-cache
|
- restore-cache
|
||||||
|
|
||||||
|
- name: lint
|
||||||
|
image: node
|
||||||
|
commands:
|
||||||
|
- yarn lint
|
||||||
|
depends_on:
|
||||||
|
- dependencies
|
||||||
|
|
||||||
- name: build_versioned
|
- name: build_versioned
|
||||||
image: node
|
image: node
|
||||||
commands:
|
commands:
|
||||||
|
@ -28,6 +37,9 @@ steps:
|
||||||
when:
|
when:
|
||||||
event:
|
event:
|
||||||
- push
|
- push
|
||||||
|
branch:
|
||||||
|
exclude:
|
||||||
|
- master
|
||||||
depends_on:
|
depends_on:
|
||||||
- dependencies
|
- dependencies
|
||||||
|
|
||||||
|
@ -72,6 +84,9 @@ steps:
|
||||||
when:
|
when:
|
||||||
event:
|
event:
|
||||||
- push
|
- push
|
||||||
|
branch:
|
||||||
|
exclude:
|
||||||
|
- master
|
||||||
depends_on:
|
depends_on:
|
||||||
- build_versioned
|
- build_versioned
|
||||||
|
|
||||||
|
@ -116,16 +131,16 @@ steps:
|
||||||
- name: test
|
- name: test
|
||||||
image: node
|
image: node
|
||||||
commands:
|
commands:
|
||||||
- yarn test:unit
|
- yarn test:unit --runInBand
|
||||||
depends_on:
|
depends_on:
|
||||||
- dependencies
|
- dependencies
|
||||||
|
|
||||||
- name: coverage
|
- name: coverage
|
||||||
image: node
|
image: node
|
||||||
commands:
|
commands:
|
||||||
- yarn test:unit --coverage
|
- yarn test:unit --coverage --runInBand
|
||||||
depends_on:
|
depends_on:
|
||||||
- dependencies
|
- test # Must run after test otherwise SQLite will get mad
|
||||||
|
|
||||||
- name: upload_coverage
|
- name: upload_coverage
|
||||||
image: plugins/s3
|
image: plugins/s3
|
||||||
|
@ -165,6 +180,7 @@ steps:
|
||||||
|
|
||||||
- name: rebuild-cache
|
- name: rebuild-cache
|
||||||
image: drillster/drone-volume-cache
|
image: drillster/drone-volume-cache
|
||||||
|
failure: ignore
|
||||||
volumes:
|
volumes:
|
||||||
- name: cache
|
- name: cache
|
||||||
path: /cache
|
path: /cache
|
||||||
|
@ -174,3 +190,13 @@ steps:
|
||||||
- ./node_modules
|
- ./node_modules
|
||||||
depends_on:
|
depends_on:
|
||||||
- dependencies
|
- dependencies
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: cache
|
||||||
|
host:
|
||||||
|
path: /opt/gitea/drone-cache/mcg/mlpcardgame
|
||||||
|
---
|
||||||
|
kind: signature
|
||||||
|
hmac: 73d743702e8545bf469f17b96c289a5d4f1d56eb3f1966c7c0a0e38d44d38aba
|
||||||
|
|
||||||
|
...
|
||||||
|
|
|
@ -8,7 +8,10 @@ module.exports = {
|
||||||
extends: ["plugin:vue/essential", "@vue/prettier", "@vue/typescript"],
|
extends: ["plugin:vue/essential", "@vue/prettier", "@vue/typescript"],
|
||||||
|
|
||||||
rules: {
|
rules: {
|
||||||
"no-console": process.env.NODE_ENV === "production" ? "error" : "off",
|
"no-console":
|
||||||
|
process.env.NODE_ENV === "production"
|
||||||
|
? ["error", { allow: ["info", "warn", "error"] }]
|
||||||
|
: "off",
|
||||||
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "off"
|
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "off"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -18,9 +21,7 @@ module.exports = {
|
||||||
|
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
files: [
|
files: ["**/__tests__/*.{j,t}s?(x)"],
|
||||||
'**/__tests__/*.{j,t}s?(x)'
|
|
||||||
],
|
|
||||||
env: {
|
env: {
|
||||||
jest: true
|
jest: true
|
||||||
}
|
}
|
||||||
|
|
1
.gitignore
vendored
|
@ -2,6 +2,7 @@
|
||||||
node_modules
|
node_modules
|
||||||
/dist
|
/dist
|
||||||
coverage
|
coverage
|
||||||
|
*.sqlite
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env.local
|
.env.local
|
||||||
|
|
52
README.md
|
@ -4,6 +4,58 @@
|
||||||
|
|
||||||
Work in progress name, work in progress game
|
Work in progress name, work in progress game
|
||||||
|
|
||||||
|
Try the latest build here: [mcg-builds.zyg.ovh/latest](https://mcg-builds.zyg.ovh/latest/)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
Fetch dependencies with `yarn`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn --dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run local server for development
|
||||||
|
|
||||||
|
Run this command:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn serve
|
||||||
|
```
|
||||||
|
|
||||||
|
then visit [localhost:8080](http://localhost:8080) (URL might be different if something is already listening on port 8080)
|
||||||
|
|
||||||
|
### Run tests
|
||||||
|
|
||||||
|
Run unit tests with Jest:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn test:unit
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate a coverage profile with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn test:unit --coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lint code
|
||||||
|
|
||||||
|
Before you submit a PR, make sure the code is formatted correctly with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building for release
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Code is ISC, Assets "depends", some stuff is taken from FreeSound and DeviantArt so your best bet is to just praise the copyright gods.
|
Code is ISC, Assets "depends", some stuff is taken from FreeSound and DeviantArt so your best bet is to just praise the copyright gods.
|
||||||
|
|
|
@ -2,7 +2,7 @@ module.exports = {
|
||||||
moduleFileExtensions: ["js", "jsx", "json", "vue", "ts", "tsx"],
|
moduleFileExtensions: ["js", "jsx", "json", "vue", "ts", "tsx"],
|
||||||
transform: {
|
transform: {
|
||||||
"^.+\\.vue$": "vue-jest",
|
"^.+\\.vue$": "vue-jest",
|
||||||
".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$":
|
".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2|webp)$":
|
||||||
"jest-transform-stub",
|
"jest-transform-stub",
|
||||||
"^.+\\.tsx?$": "ts-jest"
|
"^.+\\.tsx?$": "ts-jest"
|
||||||
},
|
},
|
||||||
|
|
23
package.json
|
@ -9,18 +9,29 @@
|
||||||
"test:unit": "vue-cli-service test:unit"
|
"test:unit": "vue-cli-service test:unit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/jszip": "^3.1.6",
|
||||||
|
"axios": "^0.18.0",
|
||||||
|
"babel-core": "7.0.0-bridge.0",
|
||||||
|
"babel-eslint": "^10.0.1",
|
||||||
"buefy": "^0.8.2",
|
"buefy": "^0.8.2",
|
||||||
|
"bulma-prefers-dark": "^0.1.0-beta.0",
|
||||||
"core-js": "^2.6.5",
|
"core-js": "^2.6.5",
|
||||||
"dexie": "^2.0.4",
|
"dexie": "^2.0.4",
|
||||||
"eventemitter3": "^4.0.0",
|
"eventemitter3": "^4.0.0",
|
||||||
|
"jszip": "^3.2.2",
|
||||||
|
"node-sass": "^4.9.0",
|
||||||
"peerjs": "^1.0.4",
|
"peerjs": "^1.0.4",
|
||||||
"register-service-worker": "^1.6.2",
|
"register-service-worker": "^1.6.2",
|
||||||
|
"sass": "^1.18.0",
|
||||||
|
"sass-loader": "^7.1.0",
|
||||||
|
"typescript": "^3.4.3",
|
||||||
"vue": "^2.6.10",
|
"vue": "^2.6.10",
|
||||||
"vue-class-component": "^7.0.2",
|
"vue-class-component": "^7.0.2",
|
||||||
"vue-property-decorator": "^8.1.0",
|
"vue-property-decorator": "^8.1.0",
|
||||||
"vue-router": "^3.0.3",
|
"vue-router": "^3.0.3",
|
||||||
"vuex": "^3.0.1",
|
"vuex": "^3.0.1",
|
||||||
"vuex-class": "^0.3.2"
|
"vuex-class": "^0.3.2",
|
||||||
|
"worker-loader": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^23.1.4",
|
"@types/jest": "^23.1.4",
|
||||||
|
@ -33,18 +44,16 @@
|
||||||
"@vue/eslint-config-prettier": "^5.0.0",
|
"@vue/eslint-config-prettier": "^5.0.0",
|
||||||
"@vue/eslint-config-typescript": "^4.0.0",
|
"@vue/eslint-config-typescript": "^4.0.0",
|
||||||
"@vue/test-utils": "^1.0.0-beta.29",
|
"@vue/test-utils": "^1.0.0-beta.29",
|
||||||
"babel-core": "7.0.0-bridge.0",
|
|
||||||
"babel-eslint": "^10.0.1",
|
|
||||||
"eslint": "^5.16.0",
|
"eslint": "^5.16.0",
|
||||||
"eslint-plugin-prettier": "^3.1.0",
|
"eslint-plugin-prettier": "^3.1.0",
|
||||||
"eslint-plugin-vue": "^5.0.0",
|
"eslint-plugin-vue": "^5.0.0",
|
||||||
"node-sass": "^4.9.0",
|
"git-describe": "^4.0.4",
|
||||||
|
"indexeddbshim": "^4.1.0",
|
||||||
"prettier": "^1.18.2",
|
"prettier": "^1.18.2",
|
||||||
"sass": "^1.18.0",
|
|
||||||
"sass-loader": "^7.1.0",
|
|
||||||
"ts-jest": "^23.0.0",
|
"ts-jest": "^23.0.0",
|
||||||
"typescript": "^3.4.3",
|
"vue-cli-plugin-axios": "^0.0.4",
|
||||||
"vue-cli-plugin-buefy": "^0.3.7",
|
"vue-cli-plugin-buefy": "^0.3.7",
|
||||||
|
"vue-cli-plugin-git-describe": "^1.0.0",
|
||||||
"vue-template-compiler": "^2.6.10"
|
"vue-template-compiler": "^2.6.10"
|
||||||
},
|
},
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
|
|
@ -1,18 +1,22 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||||
<link rel="stylesheet" href="//cdn.materialdesignicons.com/2.0.46/css/materialdesignicons.min.css">
|
<link rel="stylesheet" href="//cdn.materialdesignicons.com/2.0.46/css/materialdesignicons.min.css">
|
||||||
<title>mcgvue</title>
|
<title>MLPCARDGAME</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<noscript>
|
<noscript>
|
||||||
<strong>We're sorry but mcgvue doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
<strong>We're sorry but mcgvue doesn't work properly without JavaScript enabled. Please enable it to
|
||||||
|
continue.</strong>
|
||||||
</noscript>
|
</noscript>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<!-- built files will be auto injected -->
|
<!-- built files will be auto injected -->
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
|
@ -1,14 +1,14 @@
|
||||||
{
|
{
|
||||||
"name": "mcgvue",
|
"name": "MLPCARDGAME",
|
||||||
"short_name": "mcgvue",
|
"short_name": "mcg",
|
||||||
"icons": [
|
"description": "MLP:CCG simulator",
|
||||||
{
|
"icons": [{
|
||||||
"src": "./img/icons/android-chrome-192x192.png",
|
"src": "./images/icons/android-chrome-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./img/icons/android-chrome-512x512.png",
|
"src": "./images/icons/android-chrome-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
|
|
16
src/App.vue
|
@ -1,6 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<main v-if="loaded">
|
<main v-if="loaded">
|
||||||
<TopBar v-if="!isFullscreen" />
|
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
<main class="loading-box" v-else>
|
<main class="loading-box" v-else>
|
||||||
|
@ -9,6 +8,10 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
main {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
main.loading-box {
|
main.loading-box {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -25,15 +28,11 @@ h1.loading-message {
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Vue } from "vue-property-decorator";
|
import { Component, Vue } from "vue-property-decorator";
|
||||||
import { Action, Getter } from "vuex-class";
|
import { Action, Getter } from "vuex-class";
|
||||||
import TopBar from "@/components/Navigation/TopBar.vue";
|
import { AppState } from "@/store/types";
|
||||||
import { loadSets } from "@/mlpccg/set";
|
import { refreshCardSource, loadSets, getCards } from "@/mlpccg";
|
||||||
import { AppState } from "./store/types";
|
|
||||||
import { getCards } from "./mlpccg/database";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {}
|
||||||
TopBar
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
export default class App extends Vue {
|
export default class App extends Vue {
|
||||||
@Action showLoading!: (msg: string) => void;
|
@Action showLoading!: (msg: string) => void;
|
||||||
|
@ -54,6 +53,7 @@ export default class App extends Vue {
|
||||||
private async loadCards() {
|
private async loadCards() {
|
||||||
this.showLoading("Downloading data for all sets");
|
this.showLoading("Downloading data for all sets");
|
||||||
await loadSets();
|
await loadSets();
|
||||||
|
await refreshCardSource();
|
||||||
this.hideLoading();
|
this.hideLoading();
|
||||||
this.setLoaded(true);
|
this.setLoaded(true);
|
||||||
}
|
}
|
||||||
|
|
BIN
src/assets/images/cardback.webp
Normal file
After Width: | Height: | Size: 46 KiB |
13
src/assets/images/deckbuilder/navarrow.svg
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 50 82" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g id="Artboard1" transform="matrix(1,0,0,1,0,15.7771)">
|
||||||
|
<rect x="0" y="-15.777" width="50" height="81.873" style="fill:none;"/>
|
||||||
|
<g transform="matrix(0.457009,0,0,1.2614,2.95268,-32.3672)">
|
||||||
|
<path d="M48.243,45.605L90.773,73.875L48.243,73.875L5.712,45.605L48.243,17.336L90.773,17.336L48.243,45.605L48.243,45.605Z" style="fill:url(#_Linear1);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(137.101,65.9713,-182.089,49.6719,-34.1545,12.0871)"><stop offset="0" style="stop-color:rgb(235,235,235);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(163,163,163);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 11 KiB |
BIN
src/assets/images/elements/generosity.webp
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
src/assets/images/elements/honesty.webp
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
src/assets/images/elements/kindness.webp
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
src/assets/images/elements/laughter.webp
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
src/assets/images/elements/loyalty.webp
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
src/assets/images/elements/magic.webp
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
src/assets/images/elements/none.webp
Normal file
After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.2 KiB |
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 4 KiB |
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 799 B After Width: | Height: | Size: 799 B |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 26 KiB |
BIN
src/assets/images/races/alicorn.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
src/assets/images/races/ally.png
Normal file
After Width: | Height: | Size: 5.6 KiB |
BIN
src/assets/images/races/critter.png
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
src/assets/images/races/dragon.png
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
src/assets/images/races/earthpony.png
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/images/races/pegasus.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
src/assets/images/races/unicorn.png
Normal file
After Width: | Height: | Size: 27 KiB |
|
@ -28,7 +28,19 @@ $red: hsl(348, 100%, 61%) !default;
|
||||||
|
|
||||||
// Typography
|
// Typography
|
||||||
|
|
||||||
$family-sans-serif: BlinkMacSystemFont, -apple-system, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", "Helvetica", "Arial", sans-serif !default;
|
$family-sans-serif: BlinkMacSystemFont,
|
||||||
|
-apple-system,
|
||||||
|
"Segoe UI",
|
||||||
|
"Roboto",
|
||||||
|
"Oxygen",
|
||||||
|
"Ubuntu",
|
||||||
|
"Cantarell",
|
||||||
|
"Fira Sans",
|
||||||
|
"Droid Sans",
|
||||||
|
"Helvetica Neue",
|
||||||
|
"Helvetica",
|
||||||
|
"Arial",
|
||||||
|
sans-serif !default;
|
||||||
$family-monospace: monospace !default;
|
$family-monospace: monospace !default;
|
||||||
$render-mode: optimizeLegibility !default;
|
$render-mode: optimizeLegibility !default;
|
||||||
|
|
||||||
|
@ -72,7 +84,6 @@ $speed: 86ms !default;
|
||||||
|
|
||||||
$variable-columns: true !default;
|
$variable-columns: true !default;
|
||||||
|
|
||||||
|
|
||||||
// The default Bulma derived variables are declared below
|
// The default Bulma derived variables are declared below
|
||||||
|
|
||||||
$primary: $turquoise !default;
|
$primary: $turquoise !default;
|
||||||
|
@ -150,3 +161,10 @@ $size-small: $size-7 !default;
|
||||||
$size-normal: $size-6 !default;
|
$size-normal: $size-6 !default;
|
||||||
$size-medium: $size-5 !default;
|
$size-medium: $size-5 !default;
|
||||||
$size-large: $size-4 !default;
|
$size-large: $size-4 !default;
|
||||||
|
|
||||||
|
// Input box styling
|
||||||
|
$input-focus-border-color: $turquoise;
|
||||||
|
$input-hover-border-color: scale-color($turquoise, $lightness: -30%);
|
||||||
|
|
||||||
|
$fantasy: 'Merriweather';
|
||||||
|
$primary-text: scale-color($primary, $saturation: -50%, $lightness: 20%);
|
|
@ -4,6 +4,8 @@
|
||||||
@import "~bulma/sass/utilities/derived-variables";
|
@import "~bulma/sass/utilities/derived-variables";
|
||||||
@import "~bulma";
|
@import "~bulma";
|
||||||
@import "~buefy/src/scss/buefy";
|
@import "~buefy/src/scss/buefy";
|
||||||
|
@import "dark";
|
||||||
|
@import url('https://fonts.googleapis.com/css?family=Merriweather:300,400,400i,700&display=swap');
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scrollbar-color: #404245 #2f3132;
|
scrollbar-color: #404245 #2f3132;
|
||||||
|
@ -12,5 +14,28 @@ html {
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: white;
|
color: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-background {
|
||||||
|
background: rgba($black, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
border: 1px solid rgba($primary, 0.4);
|
||||||
|
box-shadow: 0 0 30px rgba($black, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card-body {
|
||||||
|
background: rgba($black, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card-head,
|
||||||
|
.modal-card-foot {
|
||||||
|
background: rgba($black, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
progress.progress {
|
||||||
|
filter: invert(90%);
|
||||||
}
|
}
|
6
src/assets/scss/dark.sass
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
@charset "utf-8"
|
||||||
|
@import "~bulma-prefers-dark/sass/utilities/_all"
|
||||||
|
@import "~bulma-prefers-dark/sass/base/_all"
|
||||||
|
@import "~bulma-prefers-dark/sass/elements/_all"
|
||||||
|
@import "~bulma-prefers-dark/sass/components/_all"
|
||||||
|
@import "~bulma-prefers-dark/sass/layout/_all"
|
59
src/components/Cards/CardImage.vue
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
<template>
|
||||||
|
<img :src="imageURL" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue, Prop } from "vue-property-decorator";
|
||||||
|
import { cardImageURL } from "../../mlpccg";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {}
|
||||||
|
})
|
||||||
|
export default class CardImage extends Vue {
|
||||||
|
@Prop()
|
||||||
|
private id!: string;
|
||||||
|
|
||||||
|
private loaded!: boolean;
|
||||||
|
private loadedURL!: string;
|
||||||
|
private loadedTimeout!: boolean;
|
||||||
|
|
||||||
|
private data() {
|
||||||
|
return {
|
||||||
|
loaded: false,
|
||||||
|
loadedURL: "",
|
||||||
|
loadedTimeout: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mounted() {
|
||||||
|
this.fetchImage();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.loaded) {
|
||||||
|
this.loadedTimeout = true;
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
this.$watch("id", () => {
|
||||||
|
this.loaded = false;
|
||||||
|
this.fetchImage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchImage() {
|
||||||
|
const url = await cardImageURL(this.id);
|
||||||
|
this.loaded = true;
|
||||||
|
this.loadedURL = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get imageURL(): string {
|
||||||
|
if (this.loaded) {
|
||||||
|
return this.loadedURL;
|
||||||
|
}
|
||||||
|
if (this.loadedTimeout) {
|
||||||
|
return require("@/assets/images/cardback.webp");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
98
src/components/DeckBuilder/CardPicker.vue
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
<template>
|
||||||
|
<section class="cardpicker" :style="grid">
|
||||||
|
<article
|
||||||
|
@click="() => _picked(card)"
|
||||||
|
:class="cardClass(card)"
|
||||||
|
v-for="(card, i) in cards"
|
||||||
|
:key="i + card.data.ID"
|
||||||
|
>
|
||||||
|
<CardImage :id="card.data.ID" />
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
$padding: 10px;
|
||||||
|
|
||||||
|
.cardpicker {
|
||||||
|
max-height: 100%;
|
||||||
|
display: grid;
|
||||||
|
gap: $padding;
|
||||||
|
padding: $padding;
|
||||||
|
place-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccgcard {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: 100ms all;
|
||||||
|
cursor: pointer;
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
transition: box-shadow 60ms;
|
||||||
|
}
|
||||||
|
&.available:hover img {
|
||||||
|
box-shadow: 0 0 15px 5px rgba(200, 210, 255, 0.5);
|
||||||
|
}
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue, Prop } from "vue-property-decorator";
|
||||||
|
import { Card, CardSlot, cardImageURL } from "@/mlpccg";
|
||||||
|
import CardImage from "@/components/Cards/CardImage.vue";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
CardImage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export default class CardPicker extends Vue {
|
||||||
|
@Prop()
|
||||||
|
public cards!: CardSlot[];
|
||||||
|
|
||||||
|
@Prop({ default: 2 })
|
||||||
|
public rows!: number;
|
||||||
|
|
||||||
|
@Prop({ default: 5 })
|
||||||
|
public columns!: number;
|
||||||
|
|
||||||
|
@Prop({ default: false })
|
||||||
|
public ignoreLimit!: boolean;
|
||||||
|
|
||||||
|
private get grid() {
|
||||||
|
return {
|
||||||
|
gridTemplateRows: "1fr ".repeat(this.rows).trim(),
|
||||||
|
gridTemplateColumns: "1fr ".repeat(this.columns).trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private imageURL(id: string) {
|
||||||
|
return cardImageURL(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _picked(card: CardSlot) {
|
||||||
|
if (this.isAvailable(card)) {
|
||||||
|
this.$emit("picked", card.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cardClass(card: CardSlot) {
|
||||||
|
const available = this.isAvailable(card);
|
||||||
|
return {
|
||||||
|
ccgcard: true,
|
||||||
|
available,
|
||||||
|
disabled: !available
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAvailable(card: CardSlot) {
|
||||||
|
return card.limit == 0 || card.howmany < card.limit || this.ignoreLimit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
229
src/components/DeckBuilder/DeckList.vue
Normal file
|
@ -0,0 +1,229 @@
|
||||||
|
<template>
|
||||||
|
<section class="decklist">
|
||||||
|
<section class="card-section" v-for="section in sections" :key="section">
|
||||||
|
<header>
|
||||||
|
<h1>{{ section }}</h1>
|
||||||
|
</header>
|
||||||
|
<article
|
||||||
|
class="ccgcard"
|
||||||
|
@click="() => _drop(card)"
|
||||||
|
v-for="(card, i) in getCards(section, true)"
|
||||||
|
:key="i"
|
||||||
|
>
|
||||||
|
<img :src="imageURL(card.data.ID)" class="cardbg" />
|
||||||
|
<div class="amt">{{ card.howmany }}</div>
|
||||||
|
<div class="fullname">
|
||||||
|
<div class="name">{{ card.data.Name }}</div>
|
||||||
|
<div class="subname">
|
||||||
|
{{ card.data.Subname ? card.data.Subname : "" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "@/assets/scss/_variables.scss";
|
||||||
|
|
||||||
|
.decklist {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-section {
|
||||||
|
header {
|
||||||
|
h1 {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-family: $fantasy;
|
||||||
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ccgcard {
|
||||||
|
display: flex;
|
||||||
|
align-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid $grey;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 5px 3px;
|
||||||
|
margin: 2px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
div {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardbg {
|
||||||
|
position: absolute;
|
||||||
|
margin-top: 30%;
|
||||||
|
right: -20px;
|
||||||
|
left: -20px;
|
||||||
|
max-width: none;
|
||||||
|
width: 120%;
|
||||||
|
filter: brightness(20%);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullname {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amt {
|
||||||
|
margin: 0 6pt 0 10pt;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 13pt;
|
||||||
|
&:after {
|
||||||
|
content: "×";
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-family: $fantasy;
|
||||||
|
font-size: 10.5pt;
|
||||||
|
line-height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subname {
|
||||||
|
color: $grey-light;
|
||||||
|
font-size: 10pt;
|
||||||
|
line-height: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue, Prop } from "vue-property-decorator";
|
||||||
|
import {
|
||||||
|
cardFullName,
|
||||||
|
CardSlot,
|
||||||
|
Card,
|
||||||
|
cardImageURL,
|
||||||
|
multiElemStr,
|
||||||
|
typeNames
|
||||||
|
} from "@/mlpccg";
|
||||||
|
|
||||||
|
function sortCards(a: CardSlot, b: CardSlot): number {
|
||||||
|
// Sort by element
|
||||||
|
// (Cards are guaranteed to be the same type)
|
||||||
|
switch (a.data.Type) {
|
||||||
|
case "Friend":
|
||||||
|
{
|
||||||
|
// Sort by requirement
|
||||||
|
if (a.data.Requirement && b.data.Requirement) {
|
||||||
|
const reqA = multiElemStr(Object.keys(a.data.Requirement));
|
||||||
|
const reqB = multiElemStr(Object.keys(b.data.Requirement));
|
||||||
|
if (reqA > reqB) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (reqB > reqA) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by cost
|
||||||
|
if (a.data.Cost && b.data.Cost) {
|
||||||
|
if (a.data.Cost > b.data.Cost) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (a.data.Cost < b.data.Cost) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by element
|
||||||
|
const elemA = multiElemStr(a.data.Element);
|
||||||
|
const elemB = multiElemStr(b.data.Element);
|
||||||
|
if (elemA > elemB) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (elemB > elemA) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Problem":
|
||||||
|
if (a.data.ProblemRequirement && b.data.ProblemRequirement) {
|
||||||
|
const preqA = multiElemStr(Object.keys(a.data.ProblemRequirement));
|
||||||
|
const preqB = multiElemStr(Object.keys(b.data.ProblemRequirement));
|
||||||
|
if (preqA > preqB) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (preqB > preqA) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Event":
|
||||||
|
case "Resource":
|
||||||
|
if (a.data.Requirement && b.data.Requirement) {
|
||||||
|
const reqA = multiElemStr(Object.keys(a.data.Requirement));
|
||||||
|
const reqB = multiElemStr(Object.keys(b.data.Requirement));
|
||||||
|
if (reqA > reqB) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (reqB > reqA) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by power
|
||||||
|
if (a.data.Power && b.data.Power) {
|
||||||
|
if (a.data.Power > b.data.Power) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (a.data.Power < b.data.Power) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all else fail, sort by name
|
||||||
|
const nameA = cardFullName(a.data);
|
||||||
|
const nameB = cardFullName(b.data);
|
||||||
|
if (nameA > nameB) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (nameA < nameB) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {}
|
||||||
|
})
|
||||||
|
export default class DeckList extends Vue {
|
||||||
|
@Prop()
|
||||||
|
public cards!: CardSlot[];
|
||||||
|
|
||||||
|
private fullName(card: Card): string {
|
||||||
|
return cardFullName(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _drop(slot: CardSlot) {
|
||||||
|
this.$emit("removed", slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private imageURL(id: string) {
|
||||||
|
return cardImageURL(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCards(section: string, sort: boolean): CardSlot[] {
|
||||||
|
let cards = this.cards.filter(c => c.data.Type == section);
|
||||||
|
if (!sort) {
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
return cards.sort(sortCards);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get sections(): string[] {
|
||||||
|
return typeNames.filter(s => this.getCards(s, false).length > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -1,14 +0,0 @@
|
||||||
<template>
|
|
||||||
<nav></nav>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Component, Vue } from "vue-property-decorator";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
components: {}
|
|
||||||
})
|
|
||||||
export default class TopBar extends Vue {}
|
|
||||||
</script>
|
|
124
src/components/Navigation/TopNav.vue
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
<template>
|
||||||
|
<nav>
|
||||||
|
<section class="pages">
|
||||||
|
<router-link
|
||||||
|
:class="routeClass(route)"
|
||||||
|
v-for="route in mainRoutes"
|
||||||
|
:key="route"
|
||||||
|
:to="{ name: route }"
|
||||||
|
>{{ prettyTitle(route) }}</router-link
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
<section class="icons">
|
||||||
|
<router-link
|
||||||
|
:class="routeClass(route)"
|
||||||
|
v-for="route in iconRoutes"
|
||||||
|
:key="route"
|
||||||
|
:to="{ name: route }"
|
||||||
|
><b-icon
|
||||||
|
:icon="prettyTitle(route)"
|
||||||
|
class="route-icon"
|
||||||
|
custom-size="mdi-36px"
|
||||||
|
/></router-link>
|
||||||
|
</section>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "@/assets/scss/_variables.scss";
|
||||||
|
|
||||||
|
.route-icon {
|
||||||
|
width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(200, 230, 250, 0.3),
|
||||||
|
rgba(100, 180, 255, 0.1)
|
||||||
|
);
|
||||||
|
|
||||||
|
.pages {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons {
|
||||||
|
flex-grow: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 50px;
|
||||||
|
max-width: 250px;
|
||||||
|
text-align: center;
|
||||||
|
color: $grey-lighter;
|
||||||
|
|
||||||
|
border-right: 1px solid rgba(0, 50, 100, 0.5);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(0, 0, 0, 0.5),
|
||||||
|
rgba(100, 100, 100, 0.1)
|
||||||
|
);
|
||||||
|
cursor: pointer;
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.current,
|
||||||
|
&.current:hover {
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(0, 0, 0, 0.7),
|
||||||
|
rgba(100, 100, 100, 0.1)
|
||||||
|
);
|
||||||
|
color: $grey-lighter;
|
||||||
|
cursor: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue } from "vue-property-decorator";
|
||||||
|
|
||||||
|
const mainRoutes = ["lobby", "deck-editor"];
|
||||||
|
const iconRoutes = ["settings"];
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {}
|
||||||
|
})
|
||||||
|
export default class TopNav extends Vue {
|
||||||
|
private mainRoutes!: string[];
|
||||||
|
private iconRoutes!: string[];
|
||||||
|
|
||||||
|
private data() {
|
||||||
|
return {
|
||||||
|
mainRoutes,
|
||||||
|
iconRoutes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private prettyTitle(name: string): string {
|
||||||
|
const route = this.$router.resolve({ name });
|
||||||
|
if (!route || !route.resolved.meta || !route.resolved.meta.topnav) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return route.resolved.meta.topnav;
|
||||||
|
}
|
||||||
|
|
||||||
|
private routeClass(name: string) {
|
||||||
|
return {
|
||||||
|
entry: true,
|
||||||
|
current: this.$route.name == name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -1,15 +1,19 @@
|
||||||
import Vue from "vue";
|
import Vue from "vue";
|
||||||
|
import "./plugins/axios";
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import router from "./router";
|
import router from "./router";
|
||||||
import store from "./store";
|
import store from "./store";
|
||||||
import "./registerServiceWorker";
|
import "./registerServiceWorker";
|
||||||
import Buefy from "buefy";
|
import Buefy from "buefy";
|
||||||
import "./assets/scss/app.scss";
|
import "./assets/scss/app.scss";
|
||||||
|
import { initDB } from "./mlpccg";
|
||||||
|
|
||||||
Vue.use(Buefy);
|
Vue.use(Buefy);
|
||||||
|
|
||||||
Vue.config.productionTip = false;
|
Vue.config.productionTip = false;
|
||||||
|
|
||||||
|
initDB();
|
||||||
|
|
||||||
new Vue({
|
new Vue({
|
||||||
router,
|
router,
|
||||||
store,
|
store,
|
||||||
|
|
|
@ -1,5 +1,71 @@
|
||||||
const imgBaseURL = "https://mcg.zyg.ovh/images/cards/";
|
import { Card } from "./types";
|
||||||
|
|
||||||
export function cardImageURL(cardid: string): string {
|
export function cardFullName(card: Card): string {
|
||||||
return `${imgBaseURL}${cardid}.webp`;
|
if (card.Subname != "") {
|
||||||
|
return `${card.Name}, ${card.Subname}`;
|
||||||
|
}
|
||||||
|
return card.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPonyheadURL(cards: Card[]): string {
|
||||||
|
const cardlist = cards.map(c => `${c.ID}x1`);
|
||||||
|
return "https://ponyhead.com/deckbuilder?v1code=" + cardlist.join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const colorNames = [
|
||||||
|
"Loyalty",
|
||||||
|
"Honesty",
|
||||||
|
"Laughter",
|
||||||
|
"Magic",
|
||||||
|
"Generosity",
|
||||||
|
"Kindness",
|
||||||
|
"None"
|
||||||
|
];
|
||||||
|
|
||||||
|
export const typeNames = [
|
||||||
|
"Mane Character",
|
||||||
|
"Friend",
|
||||||
|
"Event",
|
||||||
|
"Resource",
|
||||||
|
"Troublemaker",
|
||||||
|
"Problem"
|
||||||
|
];
|
||||||
|
|
||||||
|
export const rarityNames = ["C", "U", "R", "SR", "UR", "RR", "F"];
|
||||||
|
|
||||||
|
// Trasform string from list to a number that can be used for comparison/sorting
|
||||||
|
function arrIndex(arr: string[]) {
|
||||||
|
return function(comp: string) {
|
||||||
|
const idx = arr.indexOf(comp);
|
||||||
|
if (idx < 0) {
|
||||||
|
return arr.length;
|
||||||
|
}
|
||||||
|
return idx;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const elemIndex = arrIndex(colorNames);
|
||||||
|
export const typeIndex = arrIndex(typeNames);
|
||||||
|
export const rarityIndex = arrIndex(rarityNames);
|
||||||
|
|
||||||
|
// Convert Element[] to number by scaling elements for fair comparisons
|
||||||
|
// Example: ["Loyalty", "Kindness"] -> [0, 5] -> [1, 6] -> 16
|
||||||
|
export function multiElemStr(elems: string[]): number {
|
||||||
|
return elems
|
||||||
|
.map(elemIndex)
|
||||||
|
.reduce(
|
||||||
|
(acc, elem, idx, arr) => acc + (elem + 1) * 10 ** (arr.length - idx - 1),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cardLimit(type: string) {
|
||||||
|
switch (type) {
|
||||||
|
case "Mane Character":
|
||||||
|
return 1;
|
||||||
|
case "Problem":
|
||||||
|
return 2;
|
||||||
|
default:
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,21 +1,42 @@
|
||||||
import Dexie from "dexie";
|
import Dexie from "dexie";
|
||||||
import { Card, CardFilter } from "./types";
|
import { Card, CardFilter, StoredImage } from "./types";
|
||||||
|
import { cardFullName } from "./card";
|
||||||
|
|
||||||
class CardDatabase extends Dexie {
|
class CardDatabase extends Dexie {
|
||||||
public cards: Dexie.Table<Card, string>;
|
public cards: Dexie.Table<Card, string>;
|
||||||
|
public images: Dexie.Table<StoredImage, string>;
|
||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
super("CardDatabase");
|
super("CardDatabase");
|
||||||
this.version(1).stores({
|
this.version(1).stores({
|
||||||
cards: "ID,Set,Type,Cost,Power"
|
cards: "ID,Set,Type,Cost,Power",
|
||||||
|
images: "id"
|
||||||
});
|
});
|
||||||
this.cards = this.table("cards");
|
this.cards = this.table("cards");
|
||||||
|
this.images = this.table("images");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export let Database = new CardDatabase();
|
export let Database: CardDatabase | null = null;
|
||||||
|
|
||||||
|
export async function initDB(): Promise<void> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (Database == null) {
|
||||||
|
Database = new CardDatabase();
|
||||||
|
Database.on("ready", async () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
Database.open();
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCards(filter: CardFilter) {
|
export async function getCards(filter: CardFilter) {
|
||||||
|
if (Database == null) {
|
||||||
|
throw new Error("Database was not initialized, init with 'initDB()'");
|
||||||
|
}
|
||||||
let table = Database.cards;
|
let table = Database.cards;
|
||||||
// Get best IDB index
|
// Get best IDB index
|
||||||
let query: Dexie.Collection<Card, string>;
|
let query: Dexie.Collection<Card, string>;
|
||||||
|
@ -43,11 +64,10 @@ export async function getCards(filter: CardFilter) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await query
|
const results = query.filter(x => {
|
||||||
.filter(x => {
|
|
||||||
if (filter.Name) {
|
if (filter.Name) {
|
||||||
if (
|
if (
|
||||||
!`${x.Name}, ${x.Subname}`
|
!cardFullName(x)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(filter.Name.toLowerCase())
|
.includes(filter.Name.toLowerCase())
|
||||||
) {
|
) {
|
||||||
|
@ -109,15 +129,21 @@ export async function getCards(filter: CardFilter) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// For "None" element searches, "nothing" is actually ok
|
||||||
|
if (
|
||||||
|
filter.Elements.includes("None") &&
|
||||||
|
x.Element.length == 0 &&
|
||||||
|
(!x.Requirement || x.Requirement.length == 0) &&
|
||||||
|
(!x.ProblemRequirement || x.ProblemRequirement.length == 0)
|
||||||
|
) {
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
if (!found) {
|
if (!found) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (filter.Powers && filter.Powers.length > 0) {
|
if (filter.Powers && filter.Powers.length > 0) {
|
||||||
if (
|
if (typeof x.Power === "undefined" || !filter.Powers.includes(x.Power)) {
|
||||||
typeof x.Power === "undefined" ||
|
|
||||||
!filter.Powers.includes(x.Power)
|
|
||||||
) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -132,6 +158,18 @@ export async function getCards(filter: CardFilter) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
})
|
});
|
||||||
|
return await results.toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cardFromIDs(cardIDs: string[]): Promise<Card[]> {
|
||||||
|
if (Database == null) {
|
||||||
|
throw new Error("Database was not initialized, init with 'initDB()'");
|
||||||
|
}
|
||||||
|
let table = Database.cards;
|
||||||
|
//TODO Replace with .bulkGet when upgrading to Dexie 3.x
|
||||||
|
return await table
|
||||||
|
.where("ID")
|
||||||
|
.anyOf(cardIDs)
|
||||||
.toArray();
|
.toArray();
|
||||||
}
|
}
|
||||||
|
|
211
src/mlpccg/draft/booster.ts
Normal file
|
@ -0,0 +1,211 @@
|
||||||
|
import { Card, getCards } from "@/mlpccg";
|
||||||
|
import { Pack, PackSchema, AlternateProvider } from "./types";
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
(This data was taken from the MLP:CCG wikia at mlpccg.fandom.com and confirmed
|
||||||
|
by people at the MLP:CCG Discord)
|
||||||
|
|
||||||
|
Distribution rates for packs is usually 8 commons, 3 uncommons and 1 rare.
|
||||||
|
|
||||||
|
No Fixed or Promo cards can be found in packs.
|
||||||
|
|
||||||
|
UR distribution depends on set:
|
||||||
|
- PR has 1/13 chance of UR replacing a common
|
||||||
|
- CN->AD has 1/11 chance of UR replacing a common
|
||||||
|
- EO->FF has 1/3 chance of SR/UR replacing a common
|
||||||
|
|
||||||
|
SR are twice as common as UR, so that's one more thing to keep in mind.
|
||||||
|
|
||||||
|
Lastly, RR can replace another common in the ratio of ~1/2 every 6 boxes, depending
|
||||||
|
on set. Specifically, this is the RR ratio for each set:
|
||||||
|
- EO->HM: 1/108
|
||||||
|
- MT->FF: 1/216
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Returns the pack schema for a specific set
|
||||||
|
async function setSchema(set: string): Promise<PackSchema> {
|
||||||
|
// Force set name to uppercase
|
||||||
|
set = set.toUpperCase();
|
||||||
|
|
||||||
|
// Return blank schemas for invalid sets
|
||||||
|
if (set == "RR" || set == "CS") {
|
||||||
|
return { slots: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cards for set
|
||||||
|
let cards = await getCards({ Sets: [set] });
|
||||||
|
let cardMap = spanByRarity(cards);
|
||||||
|
|
||||||
|
let rr: AlternateProvider[] = [];
|
||||||
|
let srur: AlternateProvider[] = [];
|
||||||
|
|
||||||
|
// Check for RR chances
|
||||||
|
/*
|
||||||
|
switch (set) {
|
||||||
|
case "EO":
|
||||||
|
case "HM":
|
||||||
|
rr = [
|
||||||
|
{
|
||||||
|
probability: 1.0 / 108.0,
|
||||||
|
provider: randomProvider([
|
||||||
|
//TODO
|
||||||
|
])
|
||||||
|
}
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case "MT":
|
||||||
|
case "HM":
|
||||||
|
case "SB":
|
||||||
|
case "FF":
|
||||||
|
rr = [
|
||||||
|
{
|
||||||
|
probability: 1.0 / 216.0,
|
||||||
|
provider: randomProvider([
|
||||||
|
//TODO
|
||||||
|
])
|
||||||
|
}
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Check for SR/UR chances
|
||||||
|
switch (set) {
|
||||||
|
case "PR":
|
||||||
|
srur = [
|
||||||
|
{
|
||||||
|
probability: 1.0 / 13.0,
|
||||||
|
provider: randomProvider(cardMap["UR"])
|
||||||
|
}
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case "CN":
|
||||||
|
case "CG":
|
||||||
|
case "AD":
|
||||||
|
srur = [
|
||||||
|
{
|
||||||
|
probability: 1.0 / 11.0,
|
||||||
|
provider: randomProvider(cardMap["UR"])
|
||||||
|
}
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
srur = [
|
||||||
|
{
|
||||||
|
probability: (1.0 / 9.0) * 2.0,
|
||||||
|
provider: randomProvider(cardMap["SR"])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
probability: 1.0 / 9.0,
|
||||||
|
provider: randomProvider(cardMap["UR"])
|
||||||
|
}
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
slots: [
|
||||||
|
{
|
||||||
|
amount: 6,
|
||||||
|
provider: randomProvider(cardMap["C"]),
|
||||||
|
alternate: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 1,
|
||||||
|
provider: randomProvider(cardMap["C"]),
|
||||||
|
alternate: rr
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 1,
|
||||||
|
provider: randomProvider(cardMap["C"]),
|
||||||
|
alternate: srur
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 1,
|
||||||
|
provider: randomProvider(cardMap["R"]),
|
||||||
|
alternate: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
amount: 3,
|
||||||
|
provider: randomProvider(cardMap["U"]),
|
||||||
|
alternate: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PackBuilder {
|
||||||
|
schema: PackSchema;
|
||||||
|
constructor(schema: PackSchema) {
|
||||||
|
this.schema = schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildPack(): Pack {
|
||||||
|
let pack = [];
|
||||||
|
for (const slot of this.schema.slots) {
|
||||||
|
let provider = slot.provider;
|
||||||
|
|
||||||
|
// Check for alternates by generating a random and checking cumulated
|
||||||
|
// probability. Ie. if one card would show 5% of the time, another would
|
||||||
|
// show up 10% of the time, the algorithm would do something like this:
|
||||||
|
//
|
||||||
|
// With Math.random() == 0.85:
|
||||||
|
// ALTERNATE NO ALTERNATE
|
||||||
|
// [0.00-0.05][0.06----0.15][0.16------------1.00]
|
||||||
|
// ^ 0.85
|
||||||
|
//
|
||||||
|
// With Math.random() == 0.03:
|
||||||
|
// ALTERNATE NO ALTERNATE
|
||||||
|
// [0.00-0.05][0.06----0.15][0.16------------1.00]
|
||||||
|
// ^ 0.03
|
||||||
|
|
||||||
|
const rnd = Math.random();
|
||||||
|
let currentProb = 0;
|
||||||
|
for (const alternate of slot.alternate) {
|
||||||
|
currentProb += alternate.probability;
|
||||||
|
// Alternate matched
|
||||||
|
if (currentProb > rnd) {
|
||||||
|
provider = alternate.provider;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < slot.amount; i++) {
|
||||||
|
const res = provider.next();
|
||||||
|
if (res.done) {
|
||||||
|
// No more cards to get from this, exit early
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pack.push(res.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pack;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async fromSet(set: string): Promise<PackBuilder> {
|
||||||
|
let schema = await setSchema(set);
|
||||||
|
let builder = new PackBuilder(schema);
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yields random cards from a chosen pool
|
||||||
|
export function* randomProvider(pool: Card[]) {
|
||||||
|
while (true) {
|
||||||
|
const idx = Math.floor(Math.random() * pool.length);
|
||||||
|
yield pool[idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Divides a list of card to a map of rarities
|
||||||
|
// ie. [ff14, ff16, ff17] => { "C" : ["ff14"], "U": ["ff17"], "R": ["ff16"] }
|
||||||
|
export function spanByRarity(pool: Card[]): Record<string, Card[]> {
|
||||||
|
return pool.reduce((map, current) => {
|
||||||
|
if (!(current.Rarity in map)) {
|
||||||
|
map[current.Rarity] = [];
|
||||||
|
}
|
||||||
|
map[current.Rarity].push(current);
|
||||||
|
return map;
|
||||||
|
}, Object.create(null));
|
||||||
|
}
|
18
src/mlpccg/draft/bot.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { Card } from "@/mlpccg";
|
||||||
|
import { SessionPlayer } from "./session";
|
||||||
|
|
||||||
|
export class DraftBot {
|
||||||
|
assign(player: SessionPlayer) {
|
||||||
|
player.on("available-picks", cards => {
|
||||||
|
const pick = this.pick(cards);
|
||||||
|
// setTimeout hack to avoid handlers being called before the rest of the code
|
||||||
|
setTimeout(() => player.pick(pick.ID), 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pick(picks: Card[]): Card {
|
||||||
|
// For now, pick a random card
|
||||||
|
const idx = Math.floor(Math.random() * picks.length);
|
||||||
|
return picks[idx];
|
||||||
|
}
|
||||||
|
}
|
48
src/mlpccg/draft/cube.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { Card, cardFromIDs } from "@/mlpccg";
|
||||||
|
import { PackSchema } from "./types";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export class Cube {
|
||||||
|
private pool: Card[];
|
||||||
|
|
||||||
|
constructor(pool: Card[]) {
|
||||||
|
this.pool = pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
schema(): PackSchema {
|
||||||
|
return {
|
||||||
|
slots: [
|
||||||
|
{
|
||||||
|
amount: 15,
|
||||||
|
provider: this.provider(),
|
||||||
|
alternate: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
*provider() {
|
||||||
|
while (this.pool.length > 0) {
|
||||||
|
const idx = Math.floor(Math.random() * this.pool.length);
|
||||||
|
const card = this.pool.splice(idx, 1);
|
||||||
|
yield card[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async fromCardIDs(cardIDs: string[]): Promise<Cube> {
|
||||||
|
const cards = await cardFromIDs(cardIDs);
|
||||||
|
return new this(cards);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async fromList(list: string): Promise<Cube> {
|
||||||
|
const ids = list.split("\n").map(x => x.trim());
|
||||||
|
return await this.fromCardIDs(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async fromURL(url: string) {
|
||||||
|
const res = await axios(url, {
|
||||||
|
responseType: "text"
|
||||||
|
});
|
||||||
|
return await this.fromList(res.data);
|
||||||
|
}
|
||||||
|
}
|
81
src/mlpccg/draft/i8pcube.ts
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import { Card, cardFromIDs } from "@/mlpccg";
|
||||||
|
import {
|
||||||
|
PackSchema,
|
||||||
|
I8PCubeSchema,
|
||||||
|
I8PPackSchema,
|
||||||
|
I8PFileSchema,
|
||||||
|
DraftSchema
|
||||||
|
} from "./types";
|
||||||
|
import axios from "axios";
|
||||||
|
import { PackBuilder } from "./booster";
|
||||||
|
|
||||||
|
export class I8PCube {
|
||||||
|
private pools: Record<string, Card[]>;
|
||||||
|
private packschema: I8PPackSchema[];
|
||||||
|
private problemCount: number;
|
||||||
|
|
||||||
|
constructor(cubefile: I8PCubeSchema) {
|
||||||
|
this.pools = cubefile.Cards;
|
||||||
|
this.packschema = cubefile.Schema;
|
||||||
|
this.problemCount = cubefile.ProblemPackSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
schema(): DraftSchema {
|
||||||
|
return {
|
||||||
|
boosters: {
|
||||||
|
main: 4,
|
||||||
|
problem: 1
|
||||||
|
},
|
||||||
|
factories: {
|
||||||
|
main: new PackBuilder({
|
||||||
|
slots: this.packschema.map(s => ({
|
||||||
|
amount: s.Amount,
|
||||||
|
provider: this.provider(s.Type),
|
||||||
|
alternate: []
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
problem: new PackBuilder({
|
||||||
|
slots: [
|
||||||
|
{
|
||||||
|
amount: this.problemCount,
|
||||||
|
provider: this.provider("problem"),
|
||||||
|
alternate: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
*provider(name: string | "all") {
|
||||||
|
let poolname = name;
|
||||||
|
while (true) {
|
||||||
|
if (name == "all") {
|
||||||
|
const pools = Object.keys(this.pools);
|
||||||
|
const idx = Math.floor(Math.random() * pools.length);
|
||||||
|
poolname = pools[idx];
|
||||||
|
}
|
||||||
|
const pool = this.pools[poolname];
|
||||||
|
if (pool.length <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const idx = Math.floor(Math.random() * pool.length);
|
||||||
|
const card = pool.splice(idx, 1);
|
||||||
|
yield card[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async fromURL(url: string) {
|
||||||
|
const res = await axios(url);
|
||||||
|
const cubefile = res.data as I8PFileSchema;
|
||||||
|
let cards: Record<string, Card[]> = {};
|
||||||
|
for (const pool in cubefile.Cards) {
|
||||||
|
cards[pool] = await cardFromIDs(cubefile.Cards[pool]);
|
||||||
|
}
|
||||||
|
return new this({
|
||||||
|
Cards: cards,
|
||||||
|
ProblemPackSize: cubefile.ProblemPackSize,
|
||||||
|
Schema: cubefile.Schema
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
5
src/mlpccg/draft/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export * from "./cube";
|
||||||
|
export * from "./booster";
|
||||||
|
export * from "./types";
|
||||||
|
export * from "./session";
|
||||||
|
export * from "./bot";
|
34
src/mlpccg/draft/provider.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import { DraftSchema, Pack } from "./types";
|
||||||
|
import { PackBuilder } from "./booster";
|
||||||
|
|
||||||
|
export class DraftProvider {
|
||||||
|
private schema: DraftSchema;
|
||||||
|
|
||||||
|
constructor(schema: DraftSchema) {
|
||||||
|
this.schema = schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPacks(): Pack[] {
|
||||||
|
let out = [];
|
||||||
|
for (const boosterSlot in this.schema.boosters) {
|
||||||
|
const amount = this.schema.boosters[boosterSlot];
|
||||||
|
const factory = this.schema.factories[boosterSlot];
|
||||||
|
if (!factory) {
|
||||||
|
throw new Error(
|
||||||
|
`booster type ${boosterSlot} was referenced in schema but was not provided a builder`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < amount; i++) {
|
||||||
|
out.push(factory.buildPack());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static set(factory: PackBuilder, amount: number): DraftProvider {
|
||||||
|
return new DraftProvider({
|
||||||
|
boosters: { normal: amount },
|
||||||
|
factories: { normal: factory }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
262
src/mlpccg/draft/session.ts
Normal file
|
@ -0,0 +1,262 @@
|
||||||
|
import { PackBuilder, Cube, DraftOptions } from ".";
|
||||||
|
import EventEmitter from "eventemitter3";
|
||||||
|
import { Card } from "@/mlpccg";
|
||||||
|
import { Pack, Direction } from "./types";
|
||||||
|
import { DraftProvider } from "./provider";
|
||||||
|
import { DraftBot } from "./bot";
|
||||||
|
import { I8PCube } from "./i8pcube";
|
||||||
|
|
||||||
|
export class Session extends EventEmitter {
|
||||||
|
private options: DraftOptions;
|
||||||
|
private provider: DraftProvider;
|
||||||
|
private pod: SessionPlayer[] = [];
|
||||||
|
private players: string[] = [];
|
||||||
|
private pending: number[] = [];
|
||||||
|
private assigned: boolean = false;
|
||||||
|
private direction: Direction = "cw";
|
||||||
|
|
||||||
|
constructor(options: DraftOptions, provider: DraftProvider) {
|
||||||
|
super();
|
||||||
|
this.options = options;
|
||||||
|
this.provider = provider;
|
||||||
|
this.pod = new Array(options.players).fill(0).map((x, i) => {
|
||||||
|
const player = new SessionPlayer(provider.getPacks());
|
||||||
|
player.on("pick", this.picked.bind(this, i));
|
||||||
|
return player;
|
||||||
|
});
|
||||||
|
// Populate prev/next references
|
||||||
|
this.pod.forEach((val, i) => {
|
||||||
|
if (i > 0) {
|
||||||
|
val.prev = this.pod[i - 1];
|
||||||
|
} else {
|
||||||
|
val.prev = this.pod[this.pod.length - 1];
|
||||||
|
}
|
||||||
|
if (i < this.pod.length - 1) {
|
||||||
|
val.next = this.pod[i + 1];
|
||||||
|
} else {
|
||||||
|
val.next = this.pod[0];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public assign(
|
||||||
|
players: string[],
|
||||||
|
assignFn: (name: string, instance: SessionPlayer) => void
|
||||||
|
) {
|
||||||
|
// Figure out how many players there are vs spots to be filled
|
||||||
|
this.players = players;
|
||||||
|
const spots = this.options.players;
|
||||||
|
const playerNum = players.length;
|
||||||
|
if (playerNum > spots) {
|
||||||
|
throw new Error("too many players in the pod");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerNum < 1) {
|
||||||
|
throw new Error("not enough players");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place players in the pod
|
||||||
|
switch (this.options.spacing) {
|
||||||
|
case "evenly": {
|
||||||
|
const playerRatio = spots / playerNum;
|
||||||
|
let i = 0;
|
||||||
|
for (const player of players) {
|
||||||
|
const pos = Math.floor(playerRatio * i);
|
||||||
|
this.pod[pos].name = player;
|
||||||
|
assignFn(player, this.pod[pos]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "randomly":
|
||||||
|
for (const player of players) {
|
||||||
|
const free = [...Array(spots).keys()].filter(
|
||||||
|
i => this.pod[i].name == ""
|
||||||
|
);
|
||||||
|
const idx = Math.floor(Math.random() * free.length);
|
||||||
|
const chosen = free[idx];
|
||||||
|
assignFn(player, this.pod[chosen]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All the non-assigned places go to bots!
|
||||||
|
this.pod.forEach(p => {
|
||||||
|
if (p.name == "") {
|
||||||
|
p.name = "bot";
|
||||||
|
const bot = new DraftBot();
|
||||||
|
bot.assign(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.assigned = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public start() {
|
||||||
|
if (!this.assigned) {
|
||||||
|
throw new Error("Must assign players first (see assign())");
|
||||||
|
}
|
||||||
|
this.emit("start", this.order);
|
||||||
|
this.nextPack();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get order(): string[] {
|
||||||
|
return this.pod.map(p => p.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private picked(
|
||||||
|
playerIndex: number,
|
||||||
|
_card: string,
|
||||||
|
lastPick: boolean,
|
||||||
|
lastPack: boolean
|
||||||
|
) {
|
||||||
|
if (!this.pending.includes(playerIndex)) {
|
||||||
|
// Uh oh.
|
||||||
|
throw new Error(
|
||||||
|
`unexpected pick: player "${this.pod[playerIndex].name}" already picked their card`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const idx = this.pending.indexOf(playerIndex);
|
||||||
|
this.pending.splice(idx, 1);
|
||||||
|
|
||||||
|
this.emit("player-pick", this.pod[playerIndex].name);
|
||||||
|
|
||||||
|
// Don't continue unless everyone picked their card
|
||||||
|
if (this.pending.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Was this the last pick for this round of packs?
|
||||||
|
if (lastPick) {
|
||||||
|
// Was the it the last pack?
|
||||||
|
if (lastPack) {
|
||||||
|
this.emit("draft-over");
|
||||||
|
this.pod.forEach(p => p.emit("your-picks", p.picks));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.nextPack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass packs between players for next pick
|
||||||
|
this.resetPending();
|
||||||
|
this.pod.forEach(p => p.sendPack(this.direction));
|
||||||
|
this.emit("next-pick");
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextPack() {
|
||||||
|
this.resetPending();
|
||||||
|
this.flipOrder();
|
||||||
|
this.pod.forEach(p => p.nextPack());
|
||||||
|
this.emit("next-pack");
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetPending() {
|
||||||
|
this.pending = this.pod.map((_, i) => i);
|
||||||
|
}
|
||||||
|
|
||||||
|
private flipOrder() {
|
||||||
|
if (this.direction == "cw") {
|
||||||
|
this.direction = "ccw";
|
||||||
|
} else {
|
||||||
|
this.direction = "cw";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(options: DraftOptions): Promise<Session> {
|
||||||
|
switch (options.source) {
|
||||||
|
case "set": {
|
||||||
|
const factory = await PackBuilder.fromSet(options.set);
|
||||||
|
const provider = DraftProvider.set(factory, options.packs);
|
||||||
|
return new Session(options, provider);
|
||||||
|
}
|
||||||
|
case "block":
|
||||||
|
throw new Error("not implemented");
|
||||||
|
case "cube": {
|
||||||
|
const cube = await Cube.fromURL(options.url);
|
||||||
|
const factory = new PackBuilder(cube.schema());
|
||||||
|
const provider = DraftProvider.set(factory, options.packs);
|
||||||
|
return new Session(options, provider);
|
||||||
|
}
|
||||||
|
case "i8pcube": {
|
||||||
|
const cube = await I8PCube.fromURL(options.url);
|
||||||
|
const provider = new DraftProvider(cube.schema());
|
||||||
|
return new Session(options, provider);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error("Unknown draft source");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionPlayer extends EventEmitter {
|
||||||
|
public name: string = "";
|
||||||
|
public currentPack?: Pack;
|
||||||
|
public picks: Pack;
|
||||||
|
public packs: Pack[];
|
||||||
|
public toSend: Pack | null = null;
|
||||||
|
public next?: SessionPlayer;
|
||||||
|
public prev?: SessionPlayer;
|
||||||
|
public ready: boolean = false;
|
||||||
|
|
||||||
|
constructor(packs: Pack[]) {
|
||||||
|
super();
|
||||||
|
this.packs = packs;
|
||||||
|
this.picks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public pick(card: string) {
|
||||||
|
if (!this.ready) {
|
||||||
|
throw new Error("not ready to pick");
|
||||||
|
}
|
||||||
|
if (!this.currentPack) {
|
||||||
|
throw new Error("no pack to pick from");
|
||||||
|
}
|
||||||
|
const idx = this.currentPack.findIndex(c => c.ID == card);
|
||||||
|
if (idx < 0) {
|
||||||
|
throw new Error("card not in available picks");
|
||||||
|
}
|
||||||
|
const pick = this.currentPack.splice(idx, 1);
|
||||||
|
this.picks.push(pick[0]);
|
||||||
|
this.toSend = this.currentPack;
|
||||||
|
this.ready = false;
|
||||||
|
|
||||||
|
this.emit("pick", card, this.currentPack.length < 1, this.packs.length < 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendPack(direction: Direction) {
|
||||||
|
if (!this.toSend) {
|
||||||
|
throw new Error("no pack to pass");
|
||||||
|
}
|
||||||
|
if (this.toSend.length < 1) {
|
||||||
|
throw new Error("empty pack");
|
||||||
|
}
|
||||||
|
if (direction == "cw") {
|
||||||
|
if (!this.next) {
|
||||||
|
throw new Error("no player to pass cards to");
|
||||||
|
}
|
||||||
|
this.next.receivePack(this.toSend);
|
||||||
|
} else {
|
||||||
|
if (!this.prev) {
|
||||||
|
throw new Error("no player to pass cards to");
|
||||||
|
}
|
||||||
|
this.prev.receivePack(this.toSend);
|
||||||
|
}
|
||||||
|
this.toSend = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public receivePack(cards: Pack) {
|
||||||
|
this.currentPack = cards;
|
||||||
|
this.ready = true;
|
||||||
|
this.emit("available-picks", cards);
|
||||||
|
}
|
||||||
|
|
||||||
|
public nextPack() {
|
||||||
|
// Open new pack
|
||||||
|
const newPack = this.packs.shift();
|
||||||
|
if (!newPack) {
|
||||||
|
throw new Error("no packs left");
|
||||||
|
}
|
||||||
|
this.receivePack(newPack);
|
||||||
|
}
|
||||||
|
}
|
90
src/mlpccg/draft/types.ts
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import { Card } from "@/mlpccg";
|
||||||
|
import { PackBuilder } from "./booster";
|
||||||
|
|
||||||
|
export type Provider = Iterator<Card>;
|
||||||
|
|
||||||
|
export type Pack = Card[];
|
||||||
|
|
||||||
|
export interface PackSchema {
|
||||||
|
slots: PackSlot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackSlot {
|
||||||
|
amount: number;
|
||||||
|
provider: Provider;
|
||||||
|
alternate: AlternateProvider[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlternateProvider {
|
||||||
|
probability: number;
|
||||||
|
provider: Provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetDraftOptions {
|
||||||
|
source: "set";
|
||||||
|
set: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlockDraftOptions {
|
||||||
|
source: "block";
|
||||||
|
block: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CubeDraftOptions {
|
||||||
|
source: "cube";
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface I8PCubeDraftOptions {
|
||||||
|
source: "i8pcube";
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LimitedBoosterDraft {
|
||||||
|
type: "booster-draft";
|
||||||
|
packs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LimitedSealedDraft {
|
||||||
|
type: "sealed";
|
||||||
|
packs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LimitedGameType = LimitedBoosterDraft | LimitedSealedDraft;
|
||||||
|
|
||||||
|
export type DraftType =
|
||||||
|
| SetDraftOptions
|
||||||
|
| BlockDraftOptions
|
||||||
|
| CubeDraftOptions
|
||||||
|
| I8PCubeDraftOptions;
|
||||||
|
|
||||||
|
export interface SessionOptions {
|
||||||
|
players: number;
|
||||||
|
spacing: "evenly" | "randomly";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DraftOptions = SessionOptions & LimitedGameType & DraftType;
|
||||||
|
|
||||||
|
export interface DraftSchema {
|
||||||
|
boosters: Record<string, number>;
|
||||||
|
factories: Record<string, PackBuilder>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Direction = "cw" | "ccw";
|
||||||
|
|
||||||
|
export interface I8PCubeSchema {
|
||||||
|
Schema: I8PPackSchema[];
|
||||||
|
ProblemPackSize: number;
|
||||||
|
Cards: Record<string, Card[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface I8PFileSchema {
|
||||||
|
Schema: I8PPackSchema[];
|
||||||
|
ProblemPackSize: number;
|
||||||
|
Cards: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface I8PPackSchema {
|
||||||
|
Amount: number;
|
||||||
|
Type: string;
|
||||||
|
}
|
38
src/mlpccg/images.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import { Database } from "./database";
|
||||||
|
|
||||||
|
const imgBaseURL = "https://mcg.zyg.ovh/images/cards/";
|
||||||
|
let imageSource: "local" | "remote" = "remote";
|
||||||
|
|
||||||
|
export function remoteImageURL(cardid: string): string {
|
||||||
|
return `${imgBaseURL}${cardid}.webp`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cardImageURL(cardid: string): Promise<string> {
|
||||||
|
if (!Database) {
|
||||||
|
return remoteImageURL(cardid);
|
||||||
|
}
|
||||||
|
switch (cardImageSource()) {
|
||||||
|
case "local": {
|
||||||
|
const card = await Database.images.get(`${cardid}.webp`);
|
||||||
|
if (!card) {
|
||||||
|
return remoteImageURL(cardid);
|
||||||
|
}
|
||||||
|
return URL.createObjectURL(card.image);
|
||||||
|
}
|
||||||
|
//TODO
|
||||||
|
case "remote":
|
||||||
|
return remoteImageURL(cardid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cardImageSource() {
|
||||||
|
return imageSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshCardSource() {
|
||||||
|
if (!Database) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const count = await Database.images.count();
|
||||||
|
imageSource = count > 1900 ? "local" : "remote";
|
||||||
|
}
|
5
src/mlpccg/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export * from "./card";
|
||||||
|
export * from "./database";
|
||||||
|
export * from "./set";
|
||||||
|
export * from "./types";
|
||||||
|
export * from "./images";
|
|
@ -1,8 +1,9 @@
|
||||||
import { SetFile } from "./types";
|
import { SetFile } from "./types";
|
||||||
import { Database } from "./database";
|
import { Database } from "./database";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
const baseURL = "https://mcg.zyg.ovh/setdata/";
|
const baseURL = "https://mcg.zyg.ovh/setdata/";
|
||||||
const allSets = [
|
export const allSets = [
|
||||||
"PR",
|
"PR",
|
||||||
"CN",
|
"CN",
|
||||||
"RR",
|
"RR",
|
||||||
|
@ -18,7 +19,27 @@ const allSets = [
|
||||||
"Promo"
|
"Promo"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const setNames = {
|
||||||
|
PR: "Premiere",
|
||||||
|
CN: "Canterlot Nights",
|
||||||
|
RR: "Rock and Rave",
|
||||||
|
CS: "Celestial Solstice",
|
||||||
|
CG: "Crystal Games",
|
||||||
|
AD: "Absolute Discord",
|
||||||
|
EO: "Equestrian Odysseys",
|
||||||
|
HM: "High Magic",
|
||||||
|
MT: "Marks In Time",
|
||||||
|
DE: "Defenders of Equestria",
|
||||||
|
SB: "Seaquestria and Beyond",
|
||||||
|
FF: "Friends Forever",
|
||||||
|
GF: "Promotional",
|
||||||
|
ST: "Sands of Time"
|
||||||
|
};
|
||||||
|
|
||||||
export async function loadSets() {
|
export async function loadSets() {
|
||||||
|
if (Database == null) {
|
||||||
|
throw new Error("Database was not initialized, init with 'initDB()'");
|
||||||
|
}
|
||||||
const itemcount = await Database.cards.count();
|
const itemcount = await Database.cards.count();
|
||||||
if (itemcount > 100) {
|
if (itemcount > 100) {
|
||||||
// DB already filled, exit early
|
// DB already filled, exit early
|
||||||
|
@ -27,18 +48,19 @@ export async function loadSets() {
|
||||||
const sets = await Promise.all(allSets.map(set => downloadSet(set)));
|
const sets = await Promise.all(allSets.map(set => downloadSet(set)));
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
sets.map(async set => {
|
sets.map(async set => {
|
||||||
console.log(`Processing cards from ${set.Name}`);
|
if (Database == null) {
|
||||||
|
throw new Error("Database was not initialized, init with 'initDB()'");
|
||||||
|
}
|
||||||
return await Database.cards.bulkPut(set.Cards);
|
return await Database.cards.bulkPut(set.Cards);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadSet(setid: string): Promise<SetFile> {
|
async function downloadSet(setid: string): Promise<SetFile> {
|
||||||
const setfile = await fetch(`${baseURL}${setid.toLowerCase()}.json`);
|
const setdata = await axios(`${baseURL}${setid.toLowerCase()}.json`);
|
||||||
const setdata: SetFile = await setfile.json();
|
setdata.data.Cards = (setdata.data as SetFile).Cards.map(c => {
|
||||||
setdata.Cards = setdata.Cards.map(c => {
|
|
||||||
c.Set = setid;
|
c.Set = setid;
|
||||||
return c;
|
return c;
|
||||||
});
|
});
|
||||||
return setdata;
|
return setdata.data;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,11 @@ export type Rarity = "C" | "U" | "R" | "SR" | "UR" | "RR";
|
||||||
|
|
||||||
export type PowerRequirement = { [key: string]: number };
|
export type PowerRequirement = { [key: string]: number };
|
||||||
|
|
||||||
|
export interface StoredImage {
|
||||||
|
id: string;
|
||||||
|
image: Blob;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SetFile {
|
export interface SetFile {
|
||||||
Name: string;
|
Name: string;
|
||||||
Cards: Card[];
|
Cards: Card[];
|
||||||
|
@ -38,3 +43,9 @@ export interface CardFilter {
|
||||||
Powers?: number[];
|
Powers?: number[];
|
||||||
Rarities?: string[];
|
Rarities?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CardSlot {
|
||||||
|
data: Card;
|
||||||
|
limit: number;
|
||||||
|
howmany: number;
|
||||||
|
}
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
import {
|
|
||||||
PeerMetadata,
|
|
||||||
NetworkMessage,
|
|
||||||
PasswordResponse,
|
|
||||||
RoomInfo
|
|
||||||
} from "./types";
|
|
||||||
import EventEmitter from "eventemitter3";
|
import EventEmitter from "eventemitter3";
|
||||||
|
import Vue from "vue";
|
||||||
|
|
||||||
|
import { NetworkMessage, PasswordResponse, PeerMetadata, RoomInfo } from "./types";
|
||||||
|
|
||||||
export abstract class Client extends EventEmitter {
|
export abstract class Client extends EventEmitter {
|
||||||
public metadata: PeerMetadata;
|
public metadata: PeerMetadata;
|
||||||
|
@ -23,13 +20,19 @@ export abstract class Client extends EventEmitter {
|
||||||
case "room-info":
|
case "room-info":
|
||||||
this.roomInfo = data.room;
|
this.roomInfo = data.room;
|
||||||
this.players = data.players;
|
this.players = data.players;
|
||||||
|
this.emit("handshake");
|
||||||
break;
|
break;
|
||||||
// Someone changed name (or was forced to)
|
// Someone changed name (or was forced to)
|
||||||
case "rename":
|
case "rename":
|
||||||
if (data.oldname == this.metadata.name) {
|
if (data.oldname == this.metadata.name) {
|
||||||
// We got a name change!
|
// We got a name change!
|
||||||
this.metadata.name = data.newname;
|
this.metadata.name = data.newname;
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
// Only mutate player list if we have one
|
||||||
|
// This is because rename messages can be received during the initial
|
||||||
|
// handshake, to signal a forced name change before joining.
|
||||||
|
if (this.players) {
|
||||||
let idx = this.players.indexOf(data.oldname);
|
let idx = this.players.indexOf(data.oldname);
|
||||||
if (idx < 0) {
|
if (idx < 0) {
|
||||||
// Weird
|
// Weird
|
||||||
|
@ -39,7 +42,7 @@ export abstract class Client extends EventEmitter {
|
||||||
this.players.push(data.newname);
|
this.players.push(data.newname);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
this.players[idx] = data.newname;
|
Vue.set(this.players, idx, data.newname);
|
||||||
}
|
}
|
||||||
this.emit("rename", data.oldname, data.newname);
|
this.emit("rename", data.oldname, data.newname);
|
||||||
break;
|
break;
|
||||||
|
@ -48,7 +51,7 @@ export abstract class Client extends EventEmitter {
|
||||||
this.players.push(data.name);
|
this.players.push(data.name);
|
||||||
this.emit("player-joined", data.name);
|
this.emit("player-joined", data.name);
|
||||||
break;
|
break;
|
||||||
case "player-left":
|
case "player-left": {
|
||||||
let idx = this.players.indexOf(data.name);
|
let idx = this.players.indexOf(data.name);
|
||||||
if (idx < 0) {
|
if (idx < 0) {
|
||||||
// Weird
|
// Weird
|
||||||
|
@ -57,9 +60,13 @@ export abstract class Client extends EventEmitter {
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
this.emit("player-left", data.name);
|
||||||
this.players.splice(idx, 1);
|
this.players.splice(idx, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "password-req":
|
case "password-req":
|
||||||
this.emit("password-required");
|
this.emit("password-required");
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
// For most cases, we can just use the kind as event type
|
// For most cases, we can just use the kind as event type
|
||||||
this.emit(data.kind, data);
|
this.emit(data.kind, data);
|
||||||
|
|
|
@ -25,6 +25,12 @@ export class PeerClient extends Client {
|
||||||
this.connection.on("open", () => {
|
this.connection.on("open", () => {
|
||||||
this.emit("connected");
|
this.emit("connected");
|
||||||
});
|
});
|
||||||
|
this.connection.on("close", () => {
|
||||||
|
this.emit("disconnected");
|
||||||
|
});
|
||||||
|
this.connection.on("error", err => {
|
||||||
|
this.emit("error", err);
|
||||||
|
});
|
||||||
this.connection.on("data", data => {
|
this.connection.on("data", data => {
|
||||||
this._received(data);
|
this._received(data);
|
||||||
});
|
});
|
||||||
|
@ -34,6 +40,10 @@ export class PeerClient extends Client {
|
||||||
return this.metadata.name;
|
return this.metadata.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get id(): string {
|
||||||
|
return this.peer.id;
|
||||||
|
}
|
||||||
|
|
||||||
public send<T extends NetworkMessage>(data: T) {
|
public send<T extends NetworkMessage>(data: T) {
|
||||||
if (!this.connection) {
|
if (!this.connection) {
|
||||||
throw new Error("Client is not connected to a server");
|
throw new Error("Client is not connected to a server");
|
||||||
|
|
|
@ -1,23 +1,24 @@
|
||||||
|
import EventEmitter from "eventemitter3";
|
||||||
import Peer, { DataConnection } from "peerjs";
|
import Peer, { DataConnection } from "peerjs";
|
||||||
|
|
||||||
|
import { LocalClient } from ".";
|
||||||
import {
|
import {
|
||||||
RoomInfo,
|
AckMessage,
|
||||||
PasswordRequest,
|
ChatMessage,
|
||||||
Room,
|
|
||||||
ErrorMessage,
|
ErrorMessage,
|
||||||
|
JoinMessage,
|
||||||
|
LeaveMessage,
|
||||||
|
NetworkMessage,
|
||||||
|
NetworkPlayer,
|
||||||
|
PasswordRequest,
|
||||||
PasswordResponse,
|
PasswordResponse,
|
||||||
PeerMetadata,
|
PeerMetadata,
|
||||||
JoinMessage,
|
|
||||||
RoomInfoMessage,
|
|
||||||
Player,
|
Player,
|
||||||
NetworkMessage,
|
|
||||||
RenameMessage,
|
RenameMessage,
|
||||||
LeaveMessage,
|
Room,
|
||||||
NetworkPlayer,
|
RoomInfo,
|
||||||
AckMessage,
|
RoomInfoMessage,
|
||||||
ChatMessage
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { LocalClient } from ".";
|
|
||||||
import EventEmitter from "eventemitter3";
|
|
||||||
|
|
||||||
// Increment name, add number at the end if not present
|
// Increment name, add number at the end if not present
|
||||||
// Examples:
|
// Examples:
|
||||||
|
@ -34,6 +35,12 @@ function nextName(name: string): string {
|
||||||
return name.substr(0, i) + (Number(name.slice(i)) + 1);
|
return name.substr(0, i) + (Number(name.slice(i)) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function connectionOpen(conn: DataConnection): Promise<void> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
conn.on("open", () => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export class PeerServer extends EventEmitter {
|
export class PeerServer extends EventEmitter {
|
||||||
protected peer: Peer;
|
protected peer: Peer;
|
||||||
private room: Room;
|
private room: Room;
|
||||||
|
@ -64,17 +71,21 @@ export class PeerServer extends EventEmitter {
|
||||||
|
|
||||||
// Setup peer
|
// Setup peer
|
||||||
this.peer = customPeer ? customPeer : new Peer();
|
this.peer = customPeer ? customPeer : new Peer();
|
||||||
this.peer.on("open", function(id) {
|
this.peer.on("open", id => {
|
||||||
console.info("Peer ID assigned: %s", id);
|
console.info("Peer ID assigned: %s", id);
|
||||||
|
this.emit("open", id);
|
||||||
});
|
});
|
||||||
this.peer.on("connection", conn => {
|
this.peer.on("connection", conn => {
|
||||||
this._connection(conn);
|
this._connection(conn);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _connection(conn: DataConnection) {
|
private async _connection(conn: DataConnection) {
|
||||||
const metadata = conn.metadata as PeerMetadata;
|
const metadata = conn.metadata as PeerMetadata;
|
||||||
|
|
||||||
|
// Wait for connection to be open
|
||||||
|
await connectionOpen(conn);
|
||||||
|
|
||||||
let player: NetworkPlayer = {
|
let player: NetworkPlayer = {
|
||||||
kind: "remote",
|
kind: "remote",
|
||||||
name: metadata.name,
|
name: metadata.name,
|
||||||
|
@ -140,6 +151,12 @@ export class PeerServer extends EventEmitter {
|
||||||
|
|
||||||
private addPlayer(player: NetworkPlayer) {
|
private addPlayer(player: NetworkPlayer) {
|
||||||
const playerName = player.name;
|
const playerName = player.name;
|
||||||
|
|
||||||
|
// Hacky: Give player list before this player was added, so join
|
||||||
|
// message doesn't mess things up later
|
||||||
|
const players = Object.keys(this.room.players);
|
||||||
|
|
||||||
|
// Add player to room
|
||||||
this.room.players[playerName] = player;
|
this.room.players[playerName] = player;
|
||||||
|
|
||||||
// Start listening for new messages
|
// Start listening for new messages
|
||||||
|
@ -148,6 +165,10 @@ export class PeerServer extends EventEmitter {
|
||||||
this._received.bind(this, this.room.players[playerName])
|
this._received.bind(this, this.room.players[playerName])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
player.conn.on("error", err => {
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
// Send the player info about the room
|
// Send the player info about the room
|
||||||
this.send<RoomInfoMessage>(player, {
|
this.send<RoomInfoMessage>(player, {
|
||||||
kind: "room-info",
|
kind: "room-info",
|
||||||
|
@ -155,7 +176,7 @@ export class PeerServer extends EventEmitter {
|
||||||
...this.room.info,
|
...this.room.info,
|
||||||
password: ""
|
password: ""
|
||||||
},
|
},
|
||||||
players: Object.keys(this.room.players)
|
players
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify other players
|
// Notify other players
|
||||||
|
@ -172,6 +193,9 @@ export class PeerServer extends EventEmitter {
|
||||||
// Close connection with player
|
// Close connection with player
|
||||||
player.conn.close();
|
player.conn.close();
|
||||||
|
|
||||||
|
// Remove player from player list
|
||||||
|
delete this.room.players[player.name];
|
||||||
|
|
||||||
// Notify other players
|
// Notify other players
|
||||||
this.broadcast<LeaveMessage>({
|
this.broadcast<LeaveMessage>({
|
||||||
kind: "player-left",
|
kind: "player-left",
|
||||||
|
@ -189,16 +213,30 @@ export class PeerServer extends EventEmitter {
|
||||||
this.broadcast(data);
|
this.broadcast(data);
|
||||||
} else {
|
} else {
|
||||||
// Player is telling someone specifically
|
// Player is telling someone specifically
|
||||||
if (data.to in this.players) {
|
if (!(data.to in this.players)) {
|
||||||
this.send<ChatMessage>(this.players[data.to], data);
|
|
||||||
} else {
|
|
||||||
this.send<ErrorMessage>(player, {
|
this.send<ErrorMessage>(player, {
|
||||||
kind: "error",
|
kind: "error",
|
||||||
error: `player not found: ${data.to}`
|
error: `player not found: ${data.to}`
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
this.send<ChatMessage>(this.players[data.to], data);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
// Player wants to change name
|
||||||
|
case "rename":
|
||||||
|
// Make sure new name is valid
|
||||||
|
data.oldname = player.name;
|
||||||
|
if (data.newname in this.players) {
|
||||||
|
this.send<ErrorMessage>(player, {
|
||||||
|
kind: "error",
|
||||||
|
error: "name not available"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
player.name = data.newname;
|
||||||
|
this.broadcast<RenameMessage>(data);
|
||||||
|
break;
|
||||||
// Player is leaving!
|
// Player is leaving!
|
||||||
case "leave-req":
|
case "leave-req":
|
||||||
// If we're leaving, end the server
|
// If we're leaving, end the server
|
||||||
|
@ -234,6 +272,10 @@ export class PeerServer extends EventEmitter {
|
||||||
this.send<T>(player, message);
|
this.send<T>(player, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get id(): string {
|
||||||
|
return this.peer.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PeerServer;
|
export default PeerServer;
|
||||||
|
|
61
src/plugins/axios.js
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
import Vue from "vue";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
// Full config: https://github.com/axios/axios#request-config
|
||||||
|
// axios.defaults.baseURL = process.env.baseURL || process.env.apiUrl || '';
|
||||||
|
// axios.defaults.headers.common['Authorization'] = AUTH_TOKEN;
|
||||||
|
// axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||||
|
|
||||||
|
let config = {
|
||||||
|
// baseURL: process.env.baseURL || process.env.apiUrl || ""
|
||||||
|
// timeout: 60 * 1000, // Timeout
|
||||||
|
// withCredentials: true, // Check cross-site Access-Control
|
||||||
|
};
|
||||||
|
|
||||||
|
const _axios = axios.create(config);
|
||||||
|
|
||||||
|
_axios.interceptors.request.use(
|
||||||
|
function(config) {
|
||||||
|
// Do something before request is sent
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
function(error) {
|
||||||
|
// Do something with request error
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add a response interceptor
|
||||||
|
_axios.interceptors.response.use(
|
||||||
|
function(response) {
|
||||||
|
// Do something with response data
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
function(error) {
|
||||||
|
// Do something with response error
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Plugin.install = function(Vue, options) {
|
||||||
|
Vue.axios = _axios;
|
||||||
|
window.axios = _axios;
|
||||||
|
Object.defineProperties(Vue.prototype, {
|
||||||
|
axios: {
|
||||||
|
get() {
|
||||||
|
return _axios;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
$axios: {
|
||||||
|
get() {
|
||||||
|
return _axios;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Vue.use(Plugin);
|
||||||
|
|
||||||
|
export default Plugin;
|
|
@ -1,11 +1,11 @@
|
||||||
|
import DeckBuilder from "@/views/DeckBuilder.vue";
|
||||||
|
import DraftView from "@/views/Draft.vue";
|
||||||
|
import GameView from "@/views/Game.vue";
|
||||||
|
import Home from "@/views/Home.vue";
|
||||||
|
import Lobby from "@/views/Lobby.vue";
|
||||||
|
import SettingsView from "@/views/Settings.vue";
|
||||||
import Vue from "vue";
|
import Vue from "vue";
|
||||||
import Router from "vue-router";
|
import Router from "vue-router";
|
||||||
import Home from "@/views/Home.vue";
|
|
||||||
import DeckBuilder from "@/views/DeckBuilder.vue";
|
|
||||||
import GameView from "@/views/Game.vue";
|
|
||||||
import DraftView from "@/views/Draft.vue";
|
|
||||||
import Lobby from "@/views/Lobby.vue";
|
|
||||||
import RoomView from "@/views/Room.vue";
|
|
||||||
|
|
||||||
Vue.use(Router);
|
Vue.use(Router);
|
||||||
|
|
||||||
|
@ -21,7 +21,10 @@ export default new Router({
|
||||||
{
|
{
|
||||||
path: "/build",
|
path: "/build",
|
||||||
name: "deck-editor",
|
name: "deck-editor",
|
||||||
component: DeckBuilder
|
component: DeckBuilder,
|
||||||
|
meta: {
|
||||||
|
topnav: "Deck Builder"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/game",
|
path: "/game",
|
||||||
|
@ -36,12 +39,26 @@ export default new Router({
|
||||||
{
|
{
|
||||||
path: "/lobby",
|
path: "/lobby",
|
||||||
name: "lobby",
|
name: "lobby",
|
||||||
component: Lobby
|
component: Lobby,
|
||||||
|
meta: {
|
||||||
|
topnav: "Lobby"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/room",
|
path: "/join/:id",
|
||||||
name: "room",
|
name: "lobby-join",
|
||||||
component: RoomView
|
component: Lobby,
|
||||||
|
meta: {
|
||||||
|
topnav: "Lobby"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/settings",
|
||||||
|
name: "settings",
|
||||||
|
component: SettingsView,
|
||||||
|
meta: {
|
||||||
|
topnav: "settings"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
8
src/shims-worker.d.ts
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
// typings/custom.d.ts
|
||||||
|
declare module "worker-loader!*" {
|
||||||
|
class WebpackWorker extends Worker {
|
||||||
|
constructor();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WebpackWorker;
|
||||||
|
}
|
12
src/store/draft/actions.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { ActionTree } from "vuex";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
import { DraftState } from "./types";
|
||||||
|
import { Card } from "@/mlpccg";
|
||||||
|
|
||||||
|
const actions: ActionTree<DraftState, AppState> = {
|
||||||
|
pickCard({ commit }, card: Card) {
|
||||||
|
//TODO
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default actions;
|
12
src/store/draft/getters.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { GetterTree } from "vuex";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
import { DraftState } from "./types";
|
||||||
|
import { createPonyheadURL } from "@/mlpccg";
|
||||||
|
|
||||||
|
const getters: GetterTree<DraftState, AppState> = {
|
||||||
|
ponyheadURL(state): string {
|
||||||
|
return createPonyheadURL(state.picks);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getters;
|
27
src/store/draft/index.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { DraftState } from "./types";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
import { Module } from "vuex";
|
||||||
|
|
||||||
|
const namespaced = true;
|
||||||
|
|
||||||
|
import actions from "./actions";
|
||||||
|
import mutations from "./mutations";
|
||||||
|
import getters from "./getters";
|
||||||
|
|
||||||
|
export const state: DraftState = {
|
||||||
|
cards: [],
|
||||||
|
picks: [],
|
||||||
|
pod: [],
|
||||||
|
packCount: 0,
|
||||||
|
currentPack: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
export const draft: Module<DraftState, AppState> = {
|
||||||
|
namespaced,
|
||||||
|
state,
|
||||||
|
actions,
|
||||||
|
mutations,
|
||||||
|
getters
|
||||||
|
};
|
||||||
|
|
||||||
|
export default draft;
|
37
src/store/draft/mutations.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import { MutationTree } from "vuex";
|
||||||
|
import { DraftState, PlayerStatus } from "./types";
|
||||||
|
import { Card } from "@/mlpccg";
|
||||||
|
|
||||||
|
const mutations: MutationTree<DraftState> = {
|
||||||
|
playerPicked(state, payload: { name: string; picked: boolean }) {
|
||||||
|
const idx = state.pod.findIndex(p => p.name == payload.name);
|
||||||
|
state.pod[idx].picked = payload.picked;
|
||||||
|
},
|
||||||
|
|
||||||
|
resetPickStatus(state) {
|
||||||
|
state.pod = state.pod.map(p => ({ ...p, picked: false }));
|
||||||
|
},
|
||||||
|
|
||||||
|
setCardPool(state, pool: Card[]) {
|
||||||
|
state.cards = pool;
|
||||||
|
},
|
||||||
|
|
||||||
|
setPackInfo(state, payload: { current: number; total: number }) {
|
||||||
|
state.currentPack = payload.current;
|
||||||
|
state.packCount = payload.total;
|
||||||
|
},
|
||||||
|
|
||||||
|
addPicks(state, pick: Card) {
|
||||||
|
state.picks.push(pick);
|
||||||
|
},
|
||||||
|
|
||||||
|
setPod(state, pod: PlayerStatus[]) {
|
||||||
|
state.pod = pod;
|
||||||
|
},
|
||||||
|
|
||||||
|
resetPicks(state) {
|
||||||
|
state.picks = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default mutations;
|
21
src/store/draft/types.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { Session } from "@/mlpccg/draft";
|
||||||
|
import { Card } from "@/mlpccg";
|
||||||
|
|
||||||
|
export interface DraftState {
|
||||||
|
session?: Session;
|
||||||
|
|
||||||
|
pod: PlayerStatus[];
|
||||||
|
cards: Card[];
|
||||||
|
picks: Card[];
|
||||||
|
|
||||||
|
// Multiple pack draft
|
||||||
|
packCount: number;
|
||||||
|
currentPack: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerStatus {
|
||||||
|
name: string;
|
||||||
|
isBot: boolean;
|
||||||
|
isMe: boolean;
|
||||||
|
picked: boolean;
|
||||||
|
}
|
|
@ -8,6 +8,9 @@ import actions from "./actions";
|
||||||
import mutations from "./mutations";
|
import mutations from "./mutations";
|
||||||
import getters from "./getters";
|
import getters from "./getters";
|
||||||
|
|
||||||
|
import network from "./network";
|
||||||
|
import draft from "./draft";
|
||||||
|
|
||||||
const store: StoreOptions<AppState> = {
|
const store: StoreOptions<AppState> = {
|
||||||
state: {
|
state: {
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
@ -17,7 +20,10 @@ const store: StoreOptions<AppState> = {
|
||||||
actions,
|
actions,
|
||||||
mutations,
|
mutations,
|
||||||
getters,
|
getters,
|
||||||
modules: {}
|
modules: {
|
||||||
|
network,
|
||||||
|
draft
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default new Vuex.Store<AppState>(store);
|
export default new Vuex.Store<AppState>(store);
|
||||||
|
|
59
src/store/network/actions.ts
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import { ChatMessage, Client, LocalClient, NetworkMessage, PeerClient, PeerServer } from "@/network";
|
||||||
|
import { ActionTree, Commit } from "vuex";
|
||||||
|
|
||||||
|
import { AppState } from "../types";
|
||||||
|
import { ConnectOptions, NetworkState, StartServerOptions } from "./types";
|
||||||
|
|
||||||
|
function bindClientEvents(commit: Commit, client: Client) {
|
||||||
|
client.on("handshake", () => {
|
||||||
|
commit("playerListChanged", client.players);
|
||||||
|
});
|
||||||
|
client.on("player-joined", () => commit("playerListChanged", client.players));
|
||||||
|
client.on("player-left", () => commit("playerListChanged", client.players));
|
||||||
|
client.on("rename", () => commit("playerListChanged", client.players));
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions: ActionTree<NetworkState, AppState> = {
|
||||||
|
startServer({ commit }, options: StartServerOptions) {
|
||||||
|
const local = new LocalClient(options.playerInfo);
|
||||||
|
const server = new PeerServer(options.roomInfo, local, options._customPeer);
|
||||||
|
server.once("open", id => {
|
||||||
|
commit("serverAssignedID", id);
|
||||||
|
});
|
||||||
|
bindClientEvents(commit, local);
|
||||||
|
commit("becomeServer", { local, server });
|
||||||
|
},
|
||||||
|
|
||||||
|
connect({ commit }, options: ConnectOptions) {
|
||||||
|
const client = new PeerClient(options.playerInfo, options._customPeer);
|
||||||
|
commit("becomeClient", { peer: client, id: options.serverID });
|
||||||
|
client.on("connected", () => {
|
||||||
|
commit("connectionStatusChanged", "connected");
|
||||||
|
});
|
||||||
|
client.on("disconnected", () => {
|
||||||
|
commit("connectionStatusChanged", "disconnected");
|
||||||
|
});
|
||||||
|
client.on("error", err => {
|
||||||
|
commit("connectionError", err);
|
||||||
|
});
|
||||||
|
bindClientEvents(commit, client);
|
||||||
|
client.connect(options.serverID);
|
||||||
|
},
|
||||||
|
|
||||||
|
sendChatMessage({ commit, dispatch, getters }, message: ChatMessage) {
|
||||||
|
if (getters.connectionType == "none") {
|
||||||
|
throw new Error("not connected");
|
||||||
|
}
|
||||||
|
dispatch("sendMessage", message);
|
||||||
|
commit("receivedChatMessage", message);
|
||||||
|
},
|
||||||
|
|
||||||
|
sendMessage({ getters }, message: NetworkMessage) {
|
||||||
|
if (getters.connectionType == "none") {
|
||||||
|
throw new Error("not connected");
|
||||||
|
}
|
||||||
|
(getters.client as Client).send(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default actions;
|
60
src/store/network/getters.ts
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import { Client } from "@/network";
|
||||||
|
import { GetterTree } from "vuex";
|
||||||
|
|
||||||
|
import { AppState } from "../types";
|
||||||
|
import { NetworkState } from "./types";
|
||||||
|
|
||||||
|
const getters: GetterTree<NetworkState, AppState> = {
|
||||||
|
peerID(state): string | null {
|
||||||
|
switch (state.peerType) {
|
||||||
|
case "server":
|
||||||
|
return state.server.id;
|
||||||
|
case "client":
|
||||||
|
return state.peer.id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
sessionID(state): string | null {
|
||||||
|
return state.serverID;
|
||||||
|
},
|
||||||
|
|
||||||
|
client(state): Client | null {
|
||||||
|
switch (state.peerType) {
|
||||||
|
case "server":
|
||||||
|
return state.local;
|
||||||
|
case "client":
|
||||||
|
return state.peer;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
connectionType(state): "client" | "server" | "none" {
|
||||||
|
return state.peerType;
|
||||||
|
},
|
||||||
|
|
||||||
|
busy(state): boolean {
|
||||||
|
if (state.peerType == "client") {
|
||||||
|
if (state.connectionStatus == "connecting") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
inRoom(state): boolean {
|
||||||
|
if (state.peerType == "client") {
|
||||||
|
return state.connectionStatus == "connected";
|
||||||
|
}
|
||||||
|
if (state.peerType == "server") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
players(state): string[] {
|
||||||
|
return state.players;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getters;
|
30
src/store/network/index.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { NetworkState } from "./types";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
import { Module } from "vuex";
|
||||||
|
|
||||||
|
import actions from "./actions";
|
||||||
|
import mutations from "./mutations";
|
||||||
|
import getters from "./getters";
|
||||||
|
|
||||||
|
const namespaced = true;
|
||||||
|
|
||||||
|
export const state: NetworkState = {
|
||||||
|
peerType: "none",
|
||||||
|
connectionStatus: null,
|
||||||
|
peer: null,
|
||||||
|
server: null,
|
||||||
|
local: null,
|
||||||
|
serverID: null,
|
||||||
|
players: [],
|
||||||
|
chatLog: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export const network: Module<NetworkState, AppState> = {
|
||||||
|
namespaced,
|
||||||
|
state,
|
||||||
|
actions,
|
||||||
|
mutations,
|
||||||
|
getters
|
||||||
|
};
|
||||||
|
|
||||||
|
export default network;
|
44
src/store/network/mutations.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import { ChatMessage, LocalClient, PeerClient, PeerServer } from "@/network";
|
||||||
|
import Vue from "vue";
|
||||||
|
import { MutationTree } from "vuex";
|
||||||
|
|
||||||
|
import { ClientNetworkState, ConnectionStatus, NetworkState, ServerNetworkState } from "./types";
|
||||||
|
|
||||||
|
const mutations: MutationTree<NetworkState> = {
|
||||||
|
becomeServer(state, payload: { local: LocalClient; server: PeerServer }) {
|
||||||
|
state.peerType = "server";
|
||||||
|
state.players = [payload.local.name];
|
||||||
|
(state as ServerNetworkState).local = payload.local;
|
||||||
|
(state as ServerNetworkState).server = payload.server;
|
||||||
|
},
|
||||||
|
|
||||||
|
becomeClient(state, payload: { peer: PeerClient; id: string }) {
|
||||||
|
state.peerType = "client";
|
||||||
|
(state as ClientNetworkState).connectionStatus = "connecting";
|
||||||
|
(state as ClientNetworkState).peer = payload.peer;
|
||||||
|
(state as ClientNetworkState).serverID = payload.id;
|
||||||
|
},
|
||||||
|
|
||||||
|
connectionStatusChanged(state, status: ConnectionStatus) {
|
||||||
|
(state as ClientNetworkState).connectionStatus = status;
|
||||||
|
},
|
||||||
|
|
||||||
|
connectionError(state, error) {
|
||||||
|
(state as ClientNetworkState).connectionStatus = "error";
|
||||||
|
(state as ClientNetworkState).connectionError = error;
|
||||||
|
},
|
||||||
|
|
||||||
|
receivedChatMessage(state, message: ChatMessage) {
|
||||||
|
state.chatLog.push(message);
|
||||||
|
},
|
||||||
|
|
||||||
|
serverAssignedID(state, id: string) {
|
||||||
|
state.serverID = id;
|
||||||
|
},
|
||||||
|
|
||||||
|
playerListChanged(state, players: string[]) {
|
||||||
|
Vue.set(state, "players", players);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default mutations;
|
57
src/store/network/types.ts
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import { ChatMessage, LocalClient, PeerClient, PeerMetadata, PeerServer, RoomInfo } from "@/network";
|
||||||
|
import Peer from "peerjs";
|
||||||
|
|
||||||
|
export type ConnectionStatus =
|
||||||
|
| "connecting"
|
||||||
|
| "connected"
|
||||||
|
| "disconnected"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export interface SharedNetworkState {
|
||||||
|
chatLog: ChatMessage[];
|
||||||
|
serverID: string | null;
|
||||||
|
players: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoNetworkState extends SharedNetworkState {
|
||||||
|
peerType: "none";
|
||||||
|
connectionStatus: null;
|
||||||
|
connectionError?: Error;
|
||||||
|
peer: null;
|
||||||
|
server: null;
|
||||||
|
local: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientNetworkState extends SharedNetworkState {
|
||||||
|
peerType: "client";
|
||||||
|
connectionStatus: ConnectionStatus;
|
||||||
|
connectionError?: Error;
|
||||||
|
peer: PeerClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerNetworkState extends SharedNetworkState {
|
||||||
|
peerType: "server";
|
||||||
|
server: PeerServer;
|
||||||
|
local: LocalClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NetworkState =
|
||||||
|
| NoNetworkState
|
||||||
|
| ClientNetworkState
|
||||||
|
| ServerNetworkState;
|
||||||
|
|
||||||
|
export interface StartServerOptions {
|
||||||
|
roomInfo: RoomInfo;
|
||||||
|
playerInfo: PeerMetadata;
|
||||||
|
|
||||||
|
// Testing utils
|
||||||
|
_customPeer?: Peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectOptions {
|
||||||
|
serverID: string;
|
||||||
|
playerInfo: PeerMetadata;
|
||||||
|
|
||||||
|
// Testing utils
|
||||||
|
_customPeer?: Peer;
|
||||||
|
}
|
11
src/testing/IDBShim.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import Dexie from "dexie";
|
||||||
|
|
||||||
|
let init = false;
|
||||||
|
|
||||||
|
export function setupIDBShim() {
|
||||||
|
if (!init) {
|
||||||
|
const setGlobalVars = require("indexeddbshim");
|
||||||
|
setGlobalVars(Dexie.dependencies);
|
||||||
|
init = true;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,6 @@
|
||||||
export * from "./MockDataConnection";
|
export * from "./MockDataConnection";
|
||||||
export * from "./MockPeer";
|
export * from "./MockPeer";
|
||||||
export * from "./MockHelper";
|
export * from "./MockHelper";
|
||||||
|
export * from "./EventHook";
|
||||||
|
export * from "./IDBShim";
|
||||||
|
export * from "./sync-utils";
|
||||||
|
|
5
src/testing/sync-utils.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export function seconds(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
setTimeout(resolve, ms * 1000);
|
||||||
|
});
|
||||||
|
}
|
|
@ -2,4 +2,4 @@ module.exports = {
|
||||||
env: {
|
env: {
|
||||||
jest: true
|
jest: true
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
16
src/tests/unit/cards.spec.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { Card, createPonyheadURL, cardFullName } from "@/mlpccg";
|
||||||
|
|
||||||
|
describe("mlpccg/cards", () => {
|
||||||
|
test("Card full names are correctly generated in all cases", () => {
|
||||||
|
const card1 = { Name: "Name", Subname: "" };
|
||||||
|
const card2 = { Name: "Name1", Subname: "the Name2" };
|
||||||
|
expect(cardFullName(card1 as Card)).toEqual("Name");
|
||||||
|
expect(cardFullName(card2 as Card)).toEqual("Name1, the Name2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Ponyhead URL is generated correctly", () => {
|
||||||
|
const cards: any[] = [{ ID: "pr10" }, { ID: "pr12" }, { ID: "pr13" }];
|
||||||
|
const url = "https://ponyhead.com/deckbuilder?v1code=pr10x1-pr12x1-pr13x1";
|
||||||
|
expect(createPonyheadURL(cards!)).toEqual(url);
|
||||||
|
});
|
||||||
|
});
|
52
src/tests/unit/components/CardPicker.spec.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import CardPicker from "@/components/DeckBuilder/CardPicker.vue";
|
||||||
|
import CardImage from "@/components/Cards/CardImage.vue";
|
||||||
|
import { shallowMount, mount } from "@vue/test-utils";
|
||||||
|
import { seconds } from "@/testing";
|
||||||
|
|
||||||
|
// Generate 10 test cards
|
||||||
|
const testCards = new Array(10)
|
||||||
|
.fill("test")
|
||||||
|
.map((t, i) => ({ ID: `${t}${i}` }));
|
||||||
|
const testSlots = testCards.map(c => ({ data: c, limit: 3, howmany: 1 }));
|
||||||
|
|
||||||
|
describe("components/DeckBuilder/CardPicker", () => {
|
||||||
|
test("CardPicker correctly instances images for each card", () => {
|
||||||
|
const wrapper = mount(CardPicker, {
|
||||||
|
propsData: {
|
||||||
|
rows: 2,
|
||||||
|
columns: 5,
|
||||||
|
cards: testSlots
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const cards = wrapper.findAll(".ccgcard");
|
||||||
|
expect(cards.contains("img")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CardImage correctly resolves to an URL after a while", async () => {
|
||||||
|
const wrapper = mount(CardImage, {
|
||||||
|
propsData: {
|
||||||
|
id: "sb1"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let src = wrapper.attributes("src");
|
||||||
|
expect(src).toBe(""); // Should be placeholder but it gets stubbed
|
||||||
|
await seconds(0.5);
|
||||||
|
src = wrapper.attributes("src");
|
||||||
|
expect(src).toMatch(/^https?:|^blob:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CardPicker correctly aligns items in a grid", () => {
|
||||||
|
const wrapper = shallowMount(CardPicker, {
|
||||||
|
propsData: {
|
||||||
|
rows: 3,
|
||||||
|
columns: 5,
|
||||||
|
cards: testSlots
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const section = wrapper.find(".cardpicker");
|
||||||
|
const style = section.attributes("style");
|
||||||
|
expect(style).toMatch(
|
||||||
|
/grid-template-rows: \S+ \S+ \S+; grid-template-columns: \S+ \S+ \S+ \S+ \S+;/i
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
36
src/tests/unit/components/DeckList.spec.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import DeckList from "@/components/DeckBuilder/DeckList.vue";
|
||||||
|
import { shallowMount } from "@vue/test-utils";
|
||||||
|
import { colorNames } from "@/mlpccg";
|
||||||
|
|
||||||
|
// Generate 10 test cards
|
||||||
|
const testCards = new Array(3).fill("test").map((t, i) => ({
|
||||||
|
ID: `test${i}`,
|
||||||
|
Name: `Test name ${i}`,
|
||||||
|
Subname: `Subname ${i}`,
|
||||||
|
Type: "Friend",
|
||||||
|
Element: [colorNames[i]],
|
||||||
|
Power: i,
|
||||||
|
Cost: i,
|
||||||
|
Requirement: { Generosity: i }
|
||||||
|
}));
|
||||||
|
const testSlots = testCards.map((c, i) => ({ data: c, limit: 3, howmany: i }));
|
||||||
|
|
||||||
|
describe("components/DeckBuilder/DeckList", () => {
|
||||||
|
test("DeckList correctly detects card info", () => {
|
||||||
|
const wrapper = shallowMount(DeckList, {
|
||||||
|
propsData: {
|
||||||
|
cards: testSlots
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const cards = wrapper.findAll(".ccgcard");
|
||||||
|
expect(cards.contains(".fullname")).toBe(true);
|
||||||
|
for (let index = 0; index < testSlots.length; index++) {
|
||||||
|
const item = cards.at(index);
|
||||||
|
const card = testSlots[index];
|
||||||
|
expect(item.find(".amt").text()).toEqual(`${card.howmany}`);
|
||||||
|
expect(item.find(".fullname .name").text()).toEqual(card.data.Name);
|
||||||
|
expect(item.find(".fullname .subname").text()).toEqual(card.data.Subname);
|
||||||
|
//TODO Add more fields check as they are added
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
39
src/tests/unit/database.spec.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { loadSets, getCards, Database, initDB, cardFullName } from "@/mlpccg";
|
||||||
|
import { setupIDBShim } from "@/testing";
|
||||||
|
|
||||||
|
setupIDBShim();
|
||||||
|
|
||||||
|
describe("mlpccg/Database", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
jest.setTimeout(15000);
|
||||||
|
await initDB();
|
||||||
|
await loadSets();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getCards without a filter returns all the cards", async () => {
|
||||||
|
expect(Database).toBeTruthy();
|
||||||
|
const allCards = await Database!.cards.count();
|
||||||
|
const filtered = await getCards({});
|
||||||
|
expect(filtered).toHaveLength(allCards);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getCards with a primary filter filters card correctly", async () => {
|
||||||
|
expect(Database).toBeTruthy();
|
||||||
|
const filtered = await getCards({
|
||||||
|
Types: ["Troublemaker"]
|
||||||
|
});
|
||||||
|
for (const card of filtered) {
|
||||||
|
expect(card.Type).toBe("Troublemaker");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getCards with a secondary filter filters card correctly", async () => {
|
||||||
|
expect(Database).toBeTruthy();
|
||||||
|
const filtered = await getCards({
|
||||||
|
Name: "Rainbow Dash"
|
||||||
|
});
|
||||||
|
for (const card of filtered) {
|
||||||
|
expect(cardFullName(card).indexOf("Rainbow Dash") >= 0).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
131
src/tests/unit/draft.spec.ts
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
import { setupIDBShim, EventHook } from "@/testing";
|
||||||
|
import { initDB, loadSets, Database, Card } from "@/mlpccg";
|
||||||
|
import {
|
||||||
|
PackBuilder,
|
||||||
|
spanByRarity,
|
||||||
|
Cube,
|
||||||
|
Session,
|
||||||
|
DraftOptions
|
||||||
|
} from "@/mlpccg/draft";
|
||||||
|
|
||||||
|
setupIDBShim();
|
||||||
|
|
||||||
|
const testSessionOptions: DraftOptions = {
|
||||||
|
type: "booster-draft",
|
||||||
|
source: "set",
|
||||||
|
set: "FF",
|
||||||
|
packs: 2,
|
||||||
|
players: 4,
|
||||||
|
spacing: "evenly"
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("mlpccg/draft", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
jest.setTimeout(30000);
|
||||||
|
await initDB();
|
||||||
|
await loadSets();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Set booster packs are generated correctly", async () => {
|
||||||
|
expect(Database).toBeTruthy();
|
||||||
|
const builder = await PackBuilder.fromSet("FF");
|
||||||
|
const pack = builder.buildPack();
|
||||||
|
// Check pack size
|
||||||
|
expect(pack).toHaveLength(12);
|
||||||
|
const rarities = spanByRarity(pack);
|
||||||
|
// Check pack distribution
|
||||||
|
expect(rarities["R"]).toHaveLength(1);
|
||||||
|
expect(rarities["U"]).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Cube can load a newline separated card list", async () => {
|
||||||
|
expect(Database).toBeTruthy();
|
||||||
|
const cubeCards = ["ff10", "ff11", "ff12", "ff13", "ff14", "ff15"];
|
||||||
|
const cubeList = cubeCards.join("\n");
|
||||||
|
const cube = await Cube.fromList(cubeList);
|
||||||
|
const builder = new PackBuilder(cube.schema());
|
||||||
|
const pack = builder.buildPack();
|
||||||
|
// Pack size should only be 6, since there are not enough cards for a 12 cards pack
|
||||||
|
expect(pack).toHaveLength(6);
|
||||||
|
// Make sure pack has ALL the cards from the pool, no duplicates
|
||||||
|
const sortedPack = pack.map(c => c.ID).sort();
|
||||||
|
expect(sortedPack).toEqual(cubeCards);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("A session can be initialized", async () => {
|
||||||
|
expect(Database).toBeTruthy();
|
||||||
|
const session = await Session.create(testSessionOptions);
|
||||||
|
const hook = new EventHook();
|
||||||
|
hook.hookEmitter(session, "start", "session-start");
|
||||||
|
session.assign(["test1", "test2"], () => {
|
||||||
|
// Do nothing
|
||||||
|
});
|
||||||
|
session.start();
|
||||||
|
await hook.expect("session-start");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Players receive pick events and can pick cards", async () => {
|
||||||
|
expect(Database).toBeTruthy();
|
||||||
|
const session = await Session.create(testSessionOptions);
|
||||||
|
const hook = new EventHook();
|
||||||
|
hook.hookEmitter(session, "start", "session-start");
|
||||||
|
hook.hookEmitter(session, "next-pack", "session-new-pack");
|
||||||
|
hook.hookEmitter(session, "next-pick", "session-new-pick");
|
||||||
|
hook.hookEmitter(session, "player-pick", "session-picked");
|
||||||
|
hook.hookEmitter(session, "draft-over", "session-done");
|
||||||
|
session.assign(["test1", "test2"], (name, player) => {
|
||||||
|
player.on("available-picks", cards => {
|
||||||
|
// setTimeout hack to avoid handlers being called before the rest of the code
|
||||||
|
setTimeout(() => player.pick(cards[0].ID), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
session.start();
|
||||||
|
await hook.expect("session-start");
|
||||||
|
for (let i = 0; i < testSessionOptions.packs; i++) {
|
||||||
|
await hook.expect("session-new-pack");
|
||||||
|
for (let j = 0; j < 12; j++) {
|
||||||
|
for (let p = 0; p < testSessionOptions.players; p++) {
|
||||||
|
await hook.expect("session-picked");
|
||||||
|
}
|
||||||
|
if (i < testSessionOptions.packs - 1) {
|
||||||
|
await hook.expect("session-new-pick");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await hook.expect("session-done");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Sessions can load and draft I8PCube files", async () => {
|
||||||
|
expect(Database).toBeTruthy();
|
||||||
|
const session = await Session.create({
|
||||||
|
type: "booster-draft",
|
||||||
|
source: "i8pcube",
|
||||||
|
url: "https://mcg.zyg.ovh/cubes/hamchacube.json",
|
||||||
|
packs: 4,
|
||||||
|
players: 4,
|
||||||
|
spacing: "evenly"
|
||||||
|
});
|
||||||
|
const hook = new EventHook();
|
||||||
|
hook.hookEmitter(session, "start", "session-start");
|
||||||
|
session.assign(["test1"], (_, player) => {
|
||||||
|
hook.hookEmitter(player, "available-picks", "got-cards");
|
||||||
|
});
|
||||||
|
session.start();
|
||||||
|
await hook.expect("session-start");
|
||||||
|
await hook.expect("got-cards", 1000, (cards: Card[]) => {
|
||||||
|
expect(cards).toHaveLength(12);
|
||||||
|
// Check for 2 or more multicolor cards
|
||||||
|
const multicolor = cards.filter(
|
||||||
|
c =>
|
||||||
|
c.Element.length > 1 ||
|
||||||
|
(c.Requirement && Object.keys(c.Requirement).length > 1)
|
||||||
|
);
|
||||||
|
expect(multicolor.length).toBeGreaterThanOrEqual(2);
|
||||||
|
// Check for 2 or more entry cards
|
||||||
|
const entry = cards.filter(
|
||||||
|
c => !c.Requirement || c.Requirement.length < 1
|
||||||
|
);
|
||||||
|
expect(entry.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,6 +1,5 @@
|
||||||
import { MockHelper } from "@/testing";
|
import { MockHelper, EventHook } from "@/testing";
|
||||||
import { NetworkMessage, LocalClient, ChatMessage } from "@/network";
|
import { NetworkMessage, LocalClient, ChatMessage } from "@/network";
|
||||||
import { EventHook } from "@/testing/EventHook";
|
|
||||||
|
|
||||||
const sampleRoom = () => ({
|
const sampleRoom = () => ({
|
||||||
max_players: 3,
|
max_players: 3,
|
||||||
|
|
|
@ -1,14 +1,479 @@
|
||||||
<template>
|
<template>
|
||||||
<section class="deckbuilder"></section>
|
<section class="deckbuilder">
|
||||||
|
<TopNav class="topnav" />
|
||||||
|
<section class="cardlist">
|
||||||
|
<section class="filters">
|
||||||
|
<div class="row">
|
||||||
|
<b-input
|
||||||
|
@input="textChanged"
|
||||||
|
v-model="nameFilter"
|
||||||
|
placeholder="Search name"
|
||||||
|
></b-input>
|
||||||
|
<div class="colorfilter" v-for="color in colors" :key="color">
|
||||||
|
<img
|
||||||
|
@click="toggleFilter(elementFilters, color)"
|
||||||
|
:class="filterIconClass(elementFilters, color)"
|
||||||
|
:src="elementIconURL(color)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="divider" />
|
||||||
|
<div class="typefilter" v-for="type in types" :key="type">
|
||||||
|
<img
|
||||||
|
@click="toggleFilter(typeFilters, type)"
|
||||||
|
:class="filterIconClass(typeFilters, type)"
|
||||||
|
:src="typeIconURL(type)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<b-input
|
||||||
|
@input="textChanged"
|
||||||
|
v-model="ruleFilter"
|
||||||
|
placeholder="Search rule text"
|
||||||
|
></b-input>
|
||||||
|
<div class="setfilter" v-for="set in sets" :key="set">
|
||||||
|
<img
|
||||||
|
@click="toggleFilter(setFilters, set)"
|
||||||
|
:class="filterIconClass(setFilters, set)"
|
||||||
|
:src="setIconURL(set)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="cards">
|
||||||
|
<div @click="prevPage" :class="canGoPrev ? 'prev' : 'prev unavailable'">
|
||||||
|
<img src="../assets/images/deckbuilder/navarrow.svg" />
|
||||||
|
</div>
|
||||||
|
<CardPicker
|
||||||
|
@picked="cardPicked"
|
||||||
|
:columns="columns"
|
||||||
|
:rows="rows"
|
||||||
|
:cards="currentPage"
|
||||||
|
/>
|
||||||
|
<div @click="nextPage" :class="canGoNext ? 'next' : 'next unavailable'">
|
||||||
|
<img src="../assets/images/deckbuilder/navarrow.svg" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
<section class="decklist">
|
||||||
|
<header>
|
||||||
|
<h1>{{ deckname }}</h1>
|
||||||
|
<nav class="buttons">
|
||||||
|
<b-button @click="exportToPonyhead" size="is-small deck-btn"
|
||||||
|
>Ponyhead</b-button
|
||||||
|
>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<DeckList :cards="decklist" @removed="cardRemoved" />
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
<style lang="scss" scoped>
|
||||||
|
@import "@/assets/scss/_variables.scss";
|
||||||
|
|
||||||
|
.deckbuilder {
|
||||||
|
background: url("../assets/images/backgrounds/deckbuilderbg.webp") center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 3fr minmax(250px, 1fr);
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topnav {
|
||||||
|
grid-column: 1/3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardlist {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1;
|
||||||
|
grid-template-rows: 110px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 5px;
|
||||||
|
.row {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
* {
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 5% 1fr 5%;
|
||||||
|
column-gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decklist {
|
||||||
|
padding: 10px;
|
||||||
|
padding-top: 15px;
|
||||||
|
grid-column: 2;
|
||||||
|
header {
|
||||||
|
padding-left: 1rem;
|
||||||
|
color: $primary-text;
|
||||||
|
font-family: $fantasy;
|
||||||
|
h1 {
|
||||||
|
font-size: 20pt;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorfilter,
|
||||||
|
.setfilter,
|
||||||
|
.typefilter {
|
||||||
|
cursor: pointer;
|
||||||
|
img {
|
||||||
|
opacity: 0.4;
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
&.selected {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorfilter {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setfilter {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typefilter {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prev,
|
||||||
|
.next {
|
||||||
|
opacity: 0.5;
|
||||||
|
display: flex;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
&.unavailable {
|
||||||
|
opacity: 0.1;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.next img {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-btn {
|
||||||
|
padding: 0 10px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Vue } from "vue-property-decorator";
|
import { Component, Vue } from "vue-property-decorator";
|
||||||
|
import DeckList from "@/components/DeckBuilder/DeckList.vue";
|
||||||
|
import CardPicker from "@/components/DeckBuilder/CardPicker.vue";
|
||||||
|
import TopNav from "@/components/Navigation/TopNav.vue";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardFilter,
|
||||||
|
CardSlot,
|
||||||
|
getCards,
|
||||||
|
allSets,
|
||||||
|
cardFullName,
|
||||||
|
createPonyheadURL,
|
||||||
|
multiElemStr,
|
||||||
|
typeIndex,
|
||||||
|
rarityIndex,
|
||||||
|
colorNames,
|
||||||
|
typeNames,
|
||||||
|
cardLimit
|
||||||
|
} from "@/mlpccg";
|
||||||
|
|
||||||
|
// Sort function for sorting cards
|
||||||
|
function sortByColor(a: Card, b: Card) {
|
||||||
|
const typeA = typeIndex(a.Type);
|
||||||
|
const typeB = typeIndex(b.Type);
|
||||||
|
if (typeA != typeB) {
|
||||||
|
return typeA - typeB;
|
||||||
|
}
|
||||||
|
// Same types, filter by primary element
|
||||||
|
switch (a.Type) {
|
||||||
|
case "Friend":
|
||||||
|
case "Mane Character":
|
||||||
|
{
|
||||||
|
const elemA = multiElemStr(a.Element);
|
||||||
|
const elemB = multiElemStr(b.Element);
|
||||||
|
if (elemA > elemB) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (elemB > elemA) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Problem":
|
||||||
|
if (a.ProblemRequirement && b.ProblemRequirement) {
|
||||||
|
const preqA = multiElemStr(Object.keys(a.ProblemRequirement));
|
||||||
|
const preqB = multiElemStr(Object.keys(b.ProblemRequirement));
|
||||||
|
if (preqA > preqB) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (preqB > preqA) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Event":
|
||||||
|
case "Resource":
|
||||||
|
if (a.Requirement && b.Requirement) {
|
||||||
|
const reqA = multiElemStr(Object.keys(a.Requirement));
|
||||||
|
const reqB = multiElemStr(Object.keys(b.Requirement));
|
||||||
|
if (reqA > reqB) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (reqB > reqA) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Filter by power
|
||||||
|
if (a.Power && b.Power) {
|
||||||
|
if (a.Power != b.Power) {
|
||||||
|
return a.Power - b.Power;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by Rarity (not the pony)
|
||||||
|
const rarityA = rarityIndex(a.Rarity);
|
||||||
|
const rarityB = rarityIndex(b.Rarity);
|
||||||
|
if (rarityA != rarityB) {
|
||||||
|
return rarityA - rarityB;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameA = cardFullName(a);
|
||||||
|
const nameB = cardFullName(b);
|
||||||
|
if (nameA > nameB) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (nameB > nameA) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {}
|
components: {
|
||||||
|
TopNav,
|
||||||
|
DeckList,
|
||||||
|
CardPicker
|
||||||
|
}
|
||||||
})
|
})
|
||||||
export default class DeckBuilder extends Vue {}
|
export default class DeckBuilder extends Vue {
|
||||||
|
// Picked/filtered cards
|
||||||
|
private decklist!: CardSlot[];
|
||||||
|
private filtered!: Card[];
|
||||||
|
|
||||||
|
// Names
|
||||||
|
private colors!: string[];
|
||||||
|
private sets!: string[];
|
||||||
|
private types!: string[];
|
||||||
|
|
||||||
|
// Card picker size
|
||||||
|
private rows!: number;
|
||||||
|
private columns!: number;
|
||||||
|
|
||||||
|
// User Filters
|
||||||
|
private nameFilter!: string;
|
||||||
|
private ruleFilter!: string;
|
||||||
|
private setFilters!: string[];
|
||||||
|
private elementFilters!: string[];
|
||||||
|
private typeFilters!: string[];
|
||||||
|
|
||||||
|
// Decklist options
|
||||||
|
private deckname!: string;
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
private offset!: number;
|
||||||
|
|
||||||
|
private data() {
|
||||||
|
return {
|
||||||
|
decklist: [],
|
||||||
|
filtered: [],
|
||||||
|
offset: 0,
|
||||||
|
rows: 2,
|
||||||
|
columns: 5,
|
||||||
|
nameFilter: "",
|
||||||
|
ruleFilter: "",
|
||||||
|
setFilters: [],
|
||||||
|
elementFilters: [],
|
||||||
|
typeFilters: [],
|
||||||
|
colors: colorNames,
|
||||||
|
sets: allSets.slice(0, -1),
|
||||||
|
types: typeNames,
|
||||||
|
deckname: "Unnamed deck"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mounted() {
|
||||||
|
this.applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyFilters() {
|
||||||
|
let filters: CardFilter = {};
|
||||||
|
if (this.setFilters.length > 0) {
|
||||||
|
filters.Sets = this.setFilters;
|
||||||
|
}
|
||||||
|
if (this.elementFilters.length > 0) {
|
||||||
|
filters.Elements = this.elementFilters;
|
||||||
|
}
|
||||||
|
if (this.typeFilters.length > 0) {
|
||||||
|
filters.Types = this.typeFilters;
|
||||||
|
}
|
||||||
|
if (this.nameFilter.length > 0) {
|
||||||
|
filters.Name = this.nameFilter;
|
||||||
|
}
|
||||||
|
if (this.ruleFilter.length > 0) {
|
||||||
|
filters.Rules = this.ruleFilter;
|
||||||
|
}
|
||||||
|
const filtered = await getCards(filters);
|
||||||
|
this.filtered = filtered.sort(sortByColor);
|
||||||
|
this.offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get itemsPerPage() {
|
||||||
|
return this.columns * this.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get currentPage(): CardSlot[] {
|
||||||
|
return this.filtered
|
||||||
|
.slice(this.offset, this.offset + this.itemsPerPage)
|
||||||
|
.map(card => {
|
||||||
|
const res = this.decklist.find(c => c.data.ID == card.ID);
|
||||||
|
if (res) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: card,
|
||||||
|
limit: cardLimit(card.Type),
|
||||||
|
howmany: 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private elementIconURL(element: string): string {
|
||||||
|
return require(`../assets/images/elements/${element.toLowerCase()}.webp`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setIconURL(set: string): string {
|
||||||
|
return require(`../assets/images/sets/${set.toUpperCase()}.webp`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private typeIconURL(type: string): string {
|
||||||
|
let urltype = type.toLowerCase();
|
||||||
|
if (urltype == "mane character") {
|
||||||
|
urltype = "mane-char";
|
||||||
|
}
|
||||||
|
return require(`../assets/images/cardtypes/${urltype}.webp`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private filterIconClass(filter: string[], key: string) {
|
||||||
|
return {
|
||||||
|
selected: filter.includes(key)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleFilter(filter: string[], key: string) {
|
||||||
|
const idx = filter.indexOf(key);
|
||||||
|
if (idx >= 0) {
|
||||||
|
filter.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
filter.push(key);
|
||||||
|
}
|
||||||
|
this.applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
private textChanged() {
|
||||||
|
this.applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
private get canGoPrev(): boolean {
|
||||||
|
return this.offset > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get canGoNext(): boolean {
|
||||||
|
return this.offset + this.itemsPerPage < this.filtered.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private prevPage() {
|
||||||
|
if (!this.canGoPrev) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.offset = Math.max(0, this.offset - this.itemsPerPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextPage() {
|
||||||
|
if (!this.canGoNext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.offset = Math.min(
|
||||||
|
this.filtered.length - this.itemsPerPage,
|
||||||
|
this.offset + this.itemsPerPage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cardPicked(card: Card) {
|
||||||
|
// Check if card is already in
|
||||||
|
const idx = this.decklist.findIndex(c => c.data.ID == card.ID);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const deckitem = this.decklist[idx];
|
||||||
|
deckitem.howmany += 1;
|
||||||
|
Vue.set(this.decklist, idx, deckitem);
|
||||||
|
} else {
|
||||||
|
this.decklist.push({
|
||||||
|
data: card,
|
||||||
|
limit: cardLimit(card.Type),
|
||||||
|
howmany: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private exportToPonyhead() {
|
||||||
|
const url = createPonyheadURL(this.decklist.map(c => c.data));
|
||||||
|
window.open(url, "_blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
private cardRemoved(card: CardSlot) {
|
||||||
|
const idx = this.decklist.findIndex(c => c.data.ID == card.data.ID);
|
||||||
|
if (idx < 0) {
|
||||||
|
throw new Error("Removing card that isn't in the deck?");
|
||||||
|
}
|
||||||
|
const deckitem = this.decklist[idx];
|
||||||
|
deckitem.howmany -= 1;
|
||||||
|
if (deckitem.howmany <= 0) {
|
||||||
|
this.decklist.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
Vue.set(this.decklist, idx, deckitem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,23 +1,118 @@
|
||||||
<template>
|
<template>
|
||||||
<section class="draftview">
|
<section class="draftview">
|
||||||
<section class="playerlist">
|
<section class="playerlist">
|
||||||
<b>Players</b>
|
<header>
|
||||||
|
<h2>Players</h2>
|
||||||
|
</header>
|
||||||
|
<section class="players">
|
||||||
|
<article
|
||||||
|
v-for="(player, i) in playerStatus"
|
||||||
|
:key="player.name + i"
|
||||||
|
:class="playerClass(player)"
|
||||||
|
>
|
||||||
|
<div class="icon">
|
||||||
|
<b-icon v-if="player.isBot" icon="robot" size="is-small" />
|
||||||
|
<b-icon v-else icon="account-box" size="is-small" />
|
||||||
|
</div>
|
||||||
|
<div class="name">{{ player.name }}</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
<section class="pool">
|
||||||
|
<header>
|
||||||
|
<h2>
|
||||||
|
Available picks (Pack
|
||||||
|
<span class="pack-number">{{ packNumber }}</span> of
|
||||||
|
<span class="pack-count">{{ packCount }}</span
|
||||||
|
>)
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
<CardPicker
|
||||||
|
@picked="cardPicked"
|
||||||
|
:columns="columns"
|
||||||
|
:rows="rows"
|
||||||
|
:cards="currentPicks"
|
||||||
|
class="picker"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section class="cardlist">
|
||||||
|
<header>
|
||||||
|
<h2>Cards</h2>
|
||||||
|
</header>
|
||||||
|
<DeckList :cards="pickedCards" />
|
||||||
</section>
|
</section>
|
||||||
<section class="pool"><b>Card pool</b></section>
|
|
||||||
<section class="cardlist"><b>Cards</b></section>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import "@/assets/scss/_variables.scss";
|
||||||
|
|
||||||
|
$player-not-picked: $red;
|
||||||
|
$player-picked: $green;
|
||||||
|
$player-me: $purple;
|
||||||
|
$border-opacity: 0.6;
|
||||||
|
|
||||||
.draftview {
|
.draftview {
|
||||||
|
background: url("../assets/images/backgrounds/draftbg.webp") center;
|
||||||
display: grid;
|
display: grid;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
grid-gap: 10px;
|
gap: 10px;
|
||||||
grid-template-columns: 200px 1fr 250px;
|
grid-template-columns: minmax(200px, 1fr) 3fr minmax(250px, 1fr);
|
||||||
section {
|
& > section {
|
||||||
|
padding: 10px 20px;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
border: 1px solid #555;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
header > h2 {
|
||||||
|
font-family: $fantasy;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 2.5vh;
|
||||||
|
padding: 1vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerlist {
|
||||||
|
.players {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column wrap;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
.player {
|
||||||
|
border-radius: 3px;
|
||||||
|
margin: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 2px solid rgba($player-not-picked, $border-opacity);
|
||||||
|
display: flex;
|
||||||
|
color: $red;
|
||||||
|
align-items: center;
|
||||||
|
user-select: all;
|
||||||
|
&.picked,
|
||||||
|
&.bot {
|
||||||
|
color: $player-picked;
|
||||||
|
border-color: rgba($player-picked, $border-opacity);
|
||||||
|
}
|
||||||
|
&.me {
|
||||||
|
border-color: rgba($player-me, $border-opacity);
|
||||||
|
}
|
||||||
|
&.me::after {
|
||||||
|
content: " (me)";
|
||||||
|
font-size: 10pt;
|
||||||
|
margin-left: 5px;
|
||||||
|
color: $player-me;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
.picker {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-number {
|
||||||
|
color: $primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
|
@ -54,9 +149,95 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Vue } from "vue-property-decorator";
|
import { Component, Vue } from "vue-property-decorator";
|
||||||
|
import { getCards, CardSlot, cardLimit, Card } from "@/mlpccg";
|
||||||
|
import CardPicker from "@/components/DeckBuilder/CardPicker.vue";
|
||||||
|
import DeckList from "@/components/DeckBuilder/DeckList.vue";
|
||||||
|
import { PlayerStatus } from "@/store/draft/types";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {}
|
components: {
|
||||||
|
CardPicker,
|
||||||
|
DeckList
|
||||||
|
}
|
||||||
})
|
})
|
||||||
export default class DraftView extends Vue {}
|
export default class DraftView extends Vue {
|
||||||
|
// Card picker size
|
||||||
|
private rows!: number;
|
||||||
|
private columns!: number;
|
||||||
|
|
||||||
|
// Draft data
|
||||||
|
private packNumber!: number;
|
||||||
|
private packCount!: number;
|
||||||
|
private currentPicks!: CardSlot[];
|
||||||
|
private pickedCards!: CardSlot[];
|
||||||
|
|
||||||
|
private data() {
|
||||||
|
return {
|
||||||
|
packNumber: 1,
|
||||||
|
packCount: 4,
|
||||||
|
rows: 3,
|
||||||
|
columns: 4,
|
||||||
|
currentPicks: [],
|
||||||
|
pickedCards: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mounted() {
|
||||||
|
this.fillcards();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fillcards() {
|
||||||
|
const cards = await getCards({ Sets: ["FF"] });
|
||||||
|
this.currentPicks = cards.slice(0, 12).map(c => ({
|
||||||
|
data: c,
|
||||||
|
howmany: 1,
|
||||||
|
limit: 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private cardPicked(card: Card) {
|
||||||
|
// Check if card is already in
|
||||||
|
const idx = this.pickedCards.findIndex(c => c.data.ID == card.ID);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const deckitem = this.pickedCards[idx];
|
||||||
|
deckitem.howmany += 1;
|
||||||
|
Vue.set(this.pickedCards, idx, deckitem);
|
||||||
|
} else {
|
||||||
|
this.pickedCards.push({
|
||||||
|
data: card,
|
||||||
|
limit: cardLimit(card.Type),
|
||||||
|
howmany: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const origIdx = this.currentPicks.findIndex(c => c.data.ID == card.ID);
|
||||||
|
this.currentPicks.splice(origIdx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get playerStatus(): PlayerStatus[] {
|
||||||
|
return [
|
||||||
|
"Player1",
|
||||||
|
"bot",
|
||||||
|
"bot",
|
||||||
|
"Player2",
|
||||||
|
"bot",
|
||||||
|
"bot",
|
||||||
|
"Hamcha",
|
||||||
|
"bot"
|
||||||
|
].map(s => ({
|
||||||
|
name: s,
|
||||||
|
isBot: s == "bot",
|
||||||
|
isMe: s == "Hamcha",
|
||||||
|
picked: s == "b" || s == "Player2"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private playerClass(player: PlayerStatus) {
|
||||||
|
return {
|
||||||
|
player: true,
|
||||||
|
me: player.isMe,
|
||||||
|
bot: player.isBot,
|
||||||
|
picked: player.picked
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,12 +1,140 @@
|
||||||
<template>
|
<template>
|
||||||
<section class="home"></section>
|
<section class="home">
|
||||||
|
<section class="hero is-medium is-primary is-bold">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container has-text-centered">
|
||||||
|
<h1 class="title">
|
||||||
|
{{ projectName }}
|
||||||
|
</h1>
|
||||||
|
<h2 class="subtitle">
|
||||||
|
An unofficial, fan-made MLP:CCG simulator
|
||||||
|
</h2>
|
||||||
|
<br />
|
||||||
|
<b-button
|
||||||
|
tag="router-link"
|
||||||
|
to="/lobby"
|
||||||
|
class="is-primary spaced"
|
||||||
|
inverted
|
||||||
|
outlined
|
||||||
|
>Play with people</b-button
|
||||||
|
>
|
||||||
|
<b-button
|
||||||
|
tag="router-link"
|
||||||
|
to="/build"
|
||||||
|
class="is-primary spaced"
|
||||||
|
inverted
|
||||||
|
outlined
|
||||||
|
>Build a deck</b-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="section">
|
||||||
|
<div class="container is-widescreen landing-content">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<section class="has-text-centered">
|
||||||
|
<div class="title is-4">
|
||||||
|
<strong>Web-based</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div class="content">
|
||||||
|
The entire client runs in your browser. No downloads necessary!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<section class="has-text-centered">
|
||||||
|
<div class="title is-4">
|
||||||
|
<strong>Constructed</strong>
|
||||||
|
<span class="behind fantasy"> & </span>
|
||||||
|
<strong>Limited</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div class="content">
|
||||||
|
You can build decks or draft them with friends. You can even do
|
||||||
|
cube drafts!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<section class="has-text-centered">
|
||||||
|
<div class="title is-4">
|
||||||
|
<strong>Peer-to-Peer</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div class="content">
|
||||||
|
The entire client uses WebRTC for P2P communications. No
|
||||||
|
registration required to play!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container is-widescreen">
|
||||||
|
<div class="content has-text-centered">
|
||||||
|
<p>
|
||||||
|
<strong>{{ projectName }}</strong> is not affiliated with Hasbro in
|
||||||
|
any way.<br />
|
||||||
|
Come look at the
|
||||||
|
<a href="https://git.fromouter.space/mcg/mlpcardgame"
|
||||||
|
>source code</a
|
||||||
|
>
|
||||||
|
(ISC licensed)!
|
||||||
|
</p>
|
||||||
|
<p>MCG Version {{ projectVersion }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "@/assets/scss/_variables.scss";
|
||||||
|
|
||||||
|
.home {
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.behind {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fantasy {
|
||||||
|
font-family: $fantasy;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-content .column {
|
||||||
|
border: 1px solid $grey;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 10px;
|
||||||
|
.title {
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid rgba($white, 0.2);
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 11pt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spaced {
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Vue } from "vue-property-decorator";
|
import { Component, Vue } from "vue-property-decorator";
|
||||||
|
|
||||||
|
declare const GIT_DESCRIBE: { raw: string };
|
||||||
|
let versionString: string = GIT_DESCRIBE
|
||||||
|
? GIT_DESCRIBE.raw
|
||||||
|
: require("@/../package.json").version;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {}
|
components: {}
|
||||||
})
|
})
|
||||||
export default class Home extends Vue {}
|
export default class Home extends Vue {
|
||||||
|
private projectName: string = "MLPCARDGAME";
|
||||||
|
private projectVersion: string = versionString;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,14 +1,374 @@
|
||||||
<template>
|
<template>
|
||||||
<section class="lobby"></section>
|
<section class="lobby">
|
||||||
|
<TopNav />
|
||||||
|
<section v-if="!inRoom" class="body">
|
||||||
|
<section id="info">
|
||||||
|
<b-field label="Your name">
|
||||||
|
<b-input :disabled="busy" v-model="playerName"></b-input>
|
||||||
|
</b-field>
|
||||||
|
</section>
|
||||||
|
<section id="join">
|
||||||
|
<header>
|
||||||
|
<h1>Join someone's session</h1>
|
||||||
|
</header>
|
||||||
|
<b-field label="Session ID" class="only-full">
|
||||||
|
<b-input :disabled="busy" v-model="joinSessionID"></b-input>
|
||||||
|
</b-field>
|
||||||
|
<div class="center submit only-full">
|
||||||
|
<b-button
|
||||||
|
type="is-primary"
|
||||||
|
@click="join"
|
||||||
|
class="wide"
|
||||||
|
:disabled="busy || !canJoin"
|
||||||
|
>
|
||||||
|
Join
|
||||||
|
</b-button>
|
||||||
|
</div>
|
||||||
|
<div class="only-mobile">
|
||||||
|
<b-field class="full">
|
||||||
|
<b-input
|
||||||
|
:disabled="busy"
|
||||||
|
placeholder="Session ID"
|
||||||
|
v-model="joinSessionID"
|
||||||
|
></b-input>
|
||||||
|
<p class="control">
|
||||||
|
<b-button
|
||||||
|
type="is-primary"
|
||||||
|
@click="join"
|
||||||
|
:disabled="busy || !canJoin"
|
||||||
|
>
|
||||||
|
Join
|
||||||
|
</b-button>
|
||||||
|
</p>
|
||||||
|
</b-field>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="host">
|
||||||
|
<header>
|
||||||
|
<h1>Host a new session</h1>
|
||||||
|
</header>
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<b-field label="Max players">
|
||||||
|
<b-numberinput
|
||||||
|
:disabled="busy"
|
||||||
|
controls-position="compact"
|
||||||
|
v-model="hostMaxPlayers"
|
||||||
|
min="2"
|
||||||
|
max="8"
|
||||||
|
></b-numberinput>
|
||||||
|
</b-field>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<b-field label="Password">
|
||||||
|
<b-input :disabled="busy" v-model="hostPassword"></b-input>
|
||||||
|
</b-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="center">
|
||||||
|
<b-button
|
||||||
|
:disabled="busy"
|
||||||
|
type="is-primary"
|
||||||
|
@click="create"
|
||||||
|
class="wide"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</b-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
<section class="room" v-else>
|
||||||
|
<section class="info">
|
||||||
|
Invite your friends:
|
||||||
|
<span class="selectable"
|
||||||
|
><a :href="inviteLink">{{ inviteLink }}</a></span
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
<section class="chat"></section>
|
||||||
|
<section class="players">
|
||||||
|
<header>Players</header>
|
||||||
|
<ul>
|
||||||
|
<li class="selectable" v-for="player in players" :key="player">
|
||||||
|
{{ player }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section class="player-options">
|
||||||
|
<b-field>
|
||||||
|
<b-input :disabled="busy" v-model="wantedName"></b-input>
|
||||||
|
<p class="control">
|
||||||
|
<b-button
|
||||||
|
@click="changeName"
|
||||||
|
type="is-primary"
|
||||||
|
:disabled="!nameAvailable"
|
||||||
|
>Change name</b-button
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</b-field>
|
||||||
|
<b-button type="is-danger">Leave room</b-button>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
<style lang="scss" scoped>
|
||||||
|
@import "@/assets/scss/_variables.scss";
|
||||||
|
|
||||||
|
.lobby {
|
||||||
|
background: url("../assets/images/backgrounds/menubg.webp") center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
padding: 10px;
|
||||||
|
padding-top: 0;
|
||||||
|
grid-template: 150px 1fr / 1fr 1fr;
|
||||||
|
|
||||||
|
#info {
|
||||||
|
grid-row: 1;
|
||||||
|
grid-column: 1 / end;
|
||||||
|
}
|
||||||
|
|
||||||
|
#join,
|
||||||
|
#host {
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#join {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
#host {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin: 10px;
|
||||||
|
border: 1px solid rgba($white, 20%);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
header {
|
||||||
|
font-family: $fantasy;
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 18pt;
|
||||||
|
}
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide {
|
||||||
|
min-width: 30%;
|
||||||
|
margin: 10px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full {
|
||||||
|
width: 100%;
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-btn {
|
||||||
|
flex: 1;
|
||||||
|
:global(.button) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.room {
|
||||||
|
padding: 10px 20px;
|
||||||
|
margin: 10px;
|
||||||
|
border: 1px solid rgba($white, 20%);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: grid;
|
||||||
|
grid-template: 50px 1fr / 200px 1fr 300px;
|
||||||
|
|
||||||
|
.info {
|
||||||
|
grid-column: 1 / max;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.players {
|
||||||
|
header {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
grid-row: 2 / max;
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat {
|
||||||
|
grid-row: 2 / max;
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-options {
|
||||||
|
grid-row: 2 / max;
|
||||||
|
grid-column: 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.only-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectable {
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.only-full {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.only-mobile {
|
||||||
|
display: inherit;
|
||||||
|
}
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
section {
|
||||||
|
padding: 10px;
|
||||||
|
header h1 {
|
||||||
|
font-size: 14pt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.room {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
& > * {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Vue } from "vue-property-decorator";
|
import { Component, Vue } from "vue-property-decorator";
|
||||||
|
import TopNav from "@/components/Navigation/TopNav.vue";
|
||||||
|
import { StartServerOptions, ConnectOptions } from "@/store/network/types";
|
||||||
|
import { Action, Getter } from "vuex-class";
|
||||||
|
import { Client, NetworkMessage } from "@/network";
|
||||||
|
|
||||||
|
const networkNS = { namespace: "network" };
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {}
|
components: {
|
||||||
|
TopNav
|
||||||
|
}
|
||||||
})
|
})
|
||||||
export default class Lobby extends Vue {}
|
export default class Lobby extends Vue {
|
||||||
|
private playerName!: string;
|
||||||
|
private hostMaxPlayers!: number;
|
||||||
|
private hostPassword!: string;
|
||||||
|
private joinSessionID!: string;
|
||||||
|
private wantedName!: string;
|
||||||
|
|
||||||
|
@Action("startServer", networkNS)
|
||||||
|
private startServer!: (options: StartServerOptions) => void;
|
||||||
|
|
||||||
|
@Action("sendMessage", networkNS)
|
||||||
|
private sendMessage!: (message: NetworkMessage) => void;
|
||||||
|
|
||||||
|
@Action("connect", networkNS)
|
||||||
|
private connect!: (options: ConnectOptions) => void;
|
||||||
|
|
||||||
|
@Getter("inRoom", networkNS)
|
||||||
|
private inRoom!: boolean;
|
||||||
|
|
||||||
|
@Getter("busy", networkNS)
|
||||||
|
private busy!: boolean;
|
||||||
|
|
||||||
|
@Getter("sessionID", networkNS)
|
||||||
|
private sessionID!: string | null;
|
||||||
|
|
||||||
|
@Getter("players", networkNS)
|
||||||
|
private players!: string[];
|
||||||
|
|
||||||
|
private data() {
|
||||||
|
const playerName =
|
||||||
|
"Guest-" +
|
||||||
|
Math.random()
|
||||||
|
.toString()
|
||||||
|
.slice(2, 8);
|
||||||
|
return {
|
||||||
|
playerName,
|
||||||
|
hostMaxPlayers: 8,
|
||||||
|
hostPassword: "",
|
||||||
|
joinSessionID: "",
|
||||||
|
wantedName: playerName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mounted() {
|
||||||
|
if ("id" in this.$route.params) {
|
||||||
|
this.joinSessionID = this.$route.params.id;
|
||||||
|
this.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async create() {
|
||||||
|
this.wantedName = this.playerName;
|
||||||
|
this.startServer({
|
||||||
|
playerInfo: {
|
||||||
|
name: this.playerName
|
||||||
|
},
|
||||||
|
roomInfo: {
|
||||||
|
max_players: this.hostMaxPlayers,
|
||||||
|
password: this.hostPassword
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async join() {
|
||||||
|
this.wantedName = this.playerName;
|
||||||
|
this.connect({
|
||||||
|
serverID: this.joinSessionID,
|
||||||
|
playerInfo: {
|
||||||
|
name: this.playerName
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async changeName() {
|
||||||
|
this.sendMessage({
|
||||||
|
kind: "rename",
|
||||||
|
oldname: this.playerName,
|
||||||
|
newname: this.wantedName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private get canJoin(): boolean {
|
||||||
|
return this.joinSessionID != "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private get inviteLink(): string {
|
||||||
|
let subpath = "";
|
||||||
|
const joinIndex = location.pathname.indexOf("/join");
|
||||||
|
if (joinIndex > 0) {
|
||||||
|
subpath = location.pathname.substring(0, joinIndex);
|
||||||
|
}
|
||||||
|
const lobbyIndex = location.pathname.indexOf("/lobby");
|
||||||
|
if (lobbyIndex > 0) {
|
||||||
|
subpath = location.pathname.substring(0, lobbyIndex);
|
||||||
|
}
|
||||||
|
return `${location.origin}${subpath}/join/${this.sessionID}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get nameAvailable(): boolean {
|
||||||
|
return this.wantedName != "" && !this.players.includes(this.wantedName);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
<template>
|
|
||||||
<section class="room"></section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Component, Vue } from "vue-property-decorator";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
components: {}
|
|
||||||
})
|
|
||||||
export default class RoomView extends Vue {}
|
|
||||||
</script>
|
|
191
src/views/Settings.vue
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
<template>
|
||||||
|
<section class="settings">
|
||||||
|
<TopNav class="top" />
|
||||||
|
<section class="settings-box download">
|
||||||
|
<header>
|
||||||
|
<h1>Storage settings</h1>
|
||||||
|
</header>
|
||||||
|
<div class="rows">
|
||||||
|
<article>
|
||||||
|
<div class="name">
|
||||||
|
Card image source
|
||||||
|
</div>
|
||||||
|
<div class="value">
|
||||||
|
{{ imageSource }}
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<b-button
|
||||||
|
class="is-primary"
|
||||||
|
@click="downloadImages"
|
||||||
|
:disabled="cardImageSource == 'local'"
|
||||||
|
>Download images</b-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<b-modal :active.sync="isDownloading" :can-cancel="false">
|
||||||
|
<div class="modal-card" style="width: auto">
|
||||||
|
<header class="modal-card-head">
|
||||||
|
<p class="modal-card-title">
|
||||||
|
{{ downloadStatus }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<section class="modal-card-body">
|
||||||
|
<b-progress
|
||||||
|
size="is-large"
|
||||||
|
type="is-danger"
|
||||||
|
v-if="downloadProgress"
|
||||||
|
:max="downloadProgress.total"
|
||||||
|
:value="downloadProgress.progress"
|
||||||
|
show-value
|
||||||
|
>
|
||||||
|
{{ downloadProgressString }}
|
||||||
|
</b-progress>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</b-modal>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "@/assets/scss/_variables.scss";
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
grid-column: 1 / end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-box {
|
||||||
|
margin: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba($white, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
header {
|
||||||
|
h1 {
|
||||||
|
font-family: $fantasy;
|
||||||
|
font-size: 17pt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
article {
|
||||||
|
margin: 10px 0;
|
||||||
|
font-size: 12pt;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
& > div {
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
border: 1px solid rgba($black, 0.4);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue } from "vue-property-decorator";
|
||||||
|
import TopNav from "@/components/Navigation/TopNav.vue";
|
||||||
|
import { TaskRunner } from "@/workers";
|
||||||
|
import { cardImageSource, refreshCardSource } from "@/mlpccg";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: { TopNav }
|
||||||
|
})
|
||||||
|
export default class SettingsView extends Vue {
|
||||||
|
private cardImageSource!: "local" | "remote";
|
||||||
|
private downloadState!: "starting" | "download" | "extract" | null;
|
||||||
|
private downloadProgress!: { progress: number; total: number } | null;
|
||||||
|
|
||||||
|
private downloadImages() {
|
||||||
|
this.downloadState = "starting";
|
||||||
|
const worker = new TaskRunner("downloadCardImages");
|
||||||
|
worker.on("dl-progress", progress => {
|
||||||
|
if (this.downloadState != "download") {
|
||||||
|
this.downloadState = "download";
|
||||||
|
}
|
||||||
|
this.downloadProgress = progress;
|
||||||
|
});
|
||||||
|
worker.on("ex-progress", progress => {
|
||||||
|
if (this.downloadState != "extract") {
|
||||||
|
this.downloadState = "extract";
|
||||||
|
}
|
||||||
|
this.downloadProgress = progress;
|
||||||
|
});
|
||||||
|
worker.on("finish", async _ => {
|
||||||
|
this.downloadState = null;
|
||||||
|
await refreshCardSource();
|
||||||
|
this.cardImageSource = cardImageSource();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private data() {
|
||||||
|
return {
|
||||||
|
cardImageSource: cardImageSource(),
|
||||||
|
downloadState: null,
|
||||||
|
downloadProgress: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private get imageSource() {
|
||||||
|
switch (this.cardImageSource) {
|
||||||
|
case "local":
|
||||||
|
return "Local saved copy";
|
||||||
|
case "remote":
|
||||||
|
return "Remote server";
|
||||||
|
}
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private get isDownloading(): boolean {
|
||||||
|
return this.downloadState !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get downloadStatus(): string {
|
||||||
|
switch (this.downloadState) {
|
||||||
|
case "starting":
|
||||||
|
return "Starting download...";
|
||||||
|
case "download":
|
||||||
|
return `Downloading image archive (${Math.round(
|
||||||
|
this.downloadProgress!.total / 10485.76
|
||||||
|
) / 100} MB)`;
|
||||||
|
case "extract":
|
||||||
|
return `Extracting images`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private get downloadProgressString(): string {
|
||||||
|
if (!this.downloadProgress) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
let current = "";
|
||||||
|
let total = "";
|
||||||
|
if (this.downloadState == "extract") {
|
||||||
|
current = `${(this.downloadProgress.progress / 2) | 0}`;
|
||||||
|
total = `${(this.downloadProgress.total / 2) | 0}`;
|
||||||
|
} else {
|
||||||
|
const currentNum =
|
||||||
|
Math.round(this.downloadProgress.progress / 10485.76) / 100;
|
||||||
|
current = currentNum.toString().padEnd(currentNum < 10 ? 4 : 5, "0");
|
||||||
|
const totalNum = Math.round(this.downloadProgress.total / 10485.76) / 100;
|
||||||
|
total = totalNum.toString().padEnd(totalNum < 10 ? 4 : 5, "0") + " MB";
|
||||||
|
}
|
||||||
|
const percent = Math.round(
|
||||||
|
(this.downloadProgress.progress / this.downloadProgress.total) * 100
|
||||||
|
);
|
||||||
|
return `${percent}% (${current}/${total})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
1
src/workers/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from "./runner";
|
17
src/workers/runner.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import EventEmitter from "eventemitter3";
|
||||||
|
|
||||||
|
export class TaskRunner extends EventEmitter {
|
||||||
|
public class: any;
|
||||||
|
public instance: Worker;
|
||||||
|
|
||||||
|
constructor(taskName: string) {
|
||||||
|
super();
|
||||||
|
this.class = require(`worker-loader!@/workers/tasks/${taskName}`);
|
||||||
|
this.instance = new this.class() as Worker;
|
||||||
|
this.instance.addEventListener("error", ev => this.emit("error", ev));
|
||||||
|
this.instance.addEventListener("message", ev => {
|
||||||
|
const message = JSON.parse(ev.data);
|
||||||
|
this.emit(message.type, message.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
72
src/workers/tasks/downloadCardImages.ts
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import { Database, initDB } from "@/mlpccg";
|
||||||
|
import axios from "axios";
|
||||||
|
import JSZip from "jszip";
|
||||||
|
import { send, runAsync } from "../worker-utils";
|
||||||
|
|
||||||
|
async function downloadImages() {
|
||||||
|
if (!Database) {
|
||||||
|
await initDB();
|
||||||
|
}
|
||||||
|
|
||||||
|
let table = Database!.images;
|
||||||
|
|
||||||
|
const itemcount = await table.count();
|
||||||
|
if (itemcount > 1900) {
|
||||||
|
// DB already filled, exit early
|
||||||
|
return "already-done";
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipdata = await axios({
|
||||||
|
url: "https://mcg.zyg.ovh/cards.zip",
|
||||||
|
responseType: "blob",
|
||||||
|
onDownloadProgress: (progressEvent: ProgressEvent) => {
|
||||||
|
send("dl-progress", {
|
||||||
|
progress: progressEvent.loaded,
|
||||||
|
total: progressEvent.total
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const zipfile = await JSZip.loadAsync(zipdata.data);
|
||||||
|
const cards = zipfile.folder("Cards");
|
||||||
|
|
||||||
|
let loadingState = 0;
|
||||||
|
let totalLoading = 0;
|
||||||
|
cards.forEach(async () => {
|
||||||
|
totalLoading += 2;
|
||||||
|
});
|
||||||
|
let waitgroup = new Promise(resolve => {
|
||||||
|
let timer = setInterval(() => {
|
||||||
|
if (loadingState >= totalLoading) {
|
||||||
|
clearInterval(timer);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
cards.forEach(async (filename, filedata) => {
|
||||||
|
const data = await filedata.async("blob");
|
||||||
|
loadingState += 1;
|
||||||
|
send("ex-progress", {
|
||||||
|
progress: loadingState,
|
||||||
|
total: totalLoading
|
||||||
|
});
|
||||||
|
const result = await table.put({ id: filename, image: data });
|
||||||
|
loadingState += 1;
|
||||||
|
send("ex-progress", {
|
||||||
|
progress: loadingState,
|
||||||
|
total: totalLoading
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await waitgroup;
|
||||||
|
|
||||||
|
return "downloaded";
|
||||||
|
}
|
||||||
|
|
||||||
|
runAsync(async () => {
|
||||||
|
try {
|
||||||
|
return await downloadImages();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return new Error(e.message);
|
||||||
|
}
|
||||||
|
});
|
9
src/workers/worker-utils.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
export function send(type: string, data?: any) {
|
||||||
|
const ctx: Worker = self as any;
|
||||||
|
ctx.postMessage(JSON.stringify({ type, data }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAsync(fn: () => Promise<any>) {
|
||||||
|
const val = await fn();
|
||||||
|
send("finish", val);
|
||||||
|
}
|