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
|
||||
name: default
|
||||
|
||||
|
@ -14,11 +15,19 @@ steps:
|
|||
|
||||
- name: dependencies
|
||||
image: node
|
||||
failure: ignore
|
||||
commands:
|
||||
- yarn
|
||||
depends_on:
|
||||
- restore-cache
|
||||
|
||||
- name: lint
|
||||
image: node
|
||||
commands:
|
||||
- yarn lint
|
||||
depends_on:
|
||||
- dependencies
|
||||
|
||||
- name: build_versioned
|
||||
image: node
|
||||
commands:
|
||||
|
@ -28,6 +37,9 @@ steps:
|
|||
when:
|
||||
event:
|
||||
- push
|
||||
branch:
|
||||
exclude:
|
||||
- master
|
||||
depends_on:
|
||||
- dependencies
|
||||
|
||||
|
@ -72,6 +84,9 @@ steps:
|
|||
when:
|
||||
event:
|
||||
- push
|
||||
branch:
|
||||
exclude:
|
||||
- master
|
||||
depends_on:
|
||||
- build_versioned
|
||||
|
||||
|
@ -116,16 +131,16 @@ steps:
|
|||
- name: test
|
||||
image: node
|
||||
commands:
|
||||
- yarn test:unit
|
||||
- yarn test:unit --runInBand
|
||||
depends_on:
|
||||
- dependencies
|
||||
|
||||
- name: coverage
|
||||
image: node
|
||||
commands:
|
||||
- yarn test:unit --coverage
|
||||
- yarn test:unit --coverage --runInBand
|
||||
depends_on:
|
||||
- dependencies
|
||||
- test # Must run after test otherwise SQLite will get mad
|
||||
|
||||
- name: upload_coverage
|
||||
image: plugins/s3
|
||||
|
@ -165,6 +180,7 @@ steps:
|
|||
|
||||
- name: rebuild-cache
|
||||
image: drillster/drone-volume-cache
|
||||
failure: ignore
|
||||
volumes:
|
||||
- name: cache
|
||||
path: /cache
|
||||
|
@ -174,3 +190,13 @@ steps:
|
|||
- ./node_modules
|
||||
depends_on:
|
||||
- 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"],
|
||||
|
||||
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"
|
||||
},
|
||||
|
||||
|
@ -18,9 +21,7 @@ module.exports = {
|
|||
|
||||
overrides: [
|
||||
{
|
||||
files: [
|
||||
'**/__tests__/*.{j,t}s?(x)'
|
||||
],
|
||||
files: ["**/__tests__/*.{j,t}s?(x)"],
|
||||
env: {
|
||||
jest: true
|
||||
}
|
||||
|
|
1
.gitignore
vendored
|
@ -2,6 +2,7 @@
|
|||
node_modules
|
||||
/dist
|
||||
coverage
|
||||
*.sqlite
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
|
|
52
README.md
|
@ -4,6 +4,58 @@
|
|||
|
||||
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
|
||||
|
||||
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"],
|
||||
transform: {
|
||||
"^.+\\.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",
|
||||
"^.+\\.tsx?$": "ts-jest"
|
||||
},
|
||||
|
|
23
package.json
|
@ -9,18 +9,29 @@
|
|||
"test:unit": "vue-cli-service test:unit"
|
||||
},
|
||||
"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",
|
||||
"bulma-prefers-dark": "^0.1.0-beta.0",
|
||||
"core-js": "^2.6.5",
|
||||
"dexie": "^2.0.4",
|
||||
"eventemitter3": "^4.0.0",
|
||||
"jszip": "^3.2.2",
|
||||
"node-sass": "^4.9.0",
|
||||
"peerjs": "^1.0.4",
|
||||
"register-service-worker": "^1.6.2",
|
||||
"sass": "^1.18.0",
|
||||
"sass-loader": "^7.1.0",
|
||||
"typescript": "^3.4.3",
|
||||
"vue": "^2.6.10",
|
||||
"vue-class-component": "^7.0.2",
|
||||
"vue-property-decorator": "^8.1.0",
|
||||
"vue-router": "^3.0.3",
|
||||
"vuex": "^3.0.1",
|
||||
"vuex-class": "^0.3.2"
|
||||
"vuex-class": "^0.3.2",
|
||||
"worker-loader": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^23.1.4",
|
||||
|
@ -33,18 +44,16 @@
|
|||
"@vue/eslint-config-prettier": "^5.0.0",
|
||||
"@vue/eslint-config-typescript": "^4.0.0",
|
||||
"@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-plugin-prettier": "^3.1.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",
|
||||
"sass": "^1.18.0",
|
||||
"sass-loader": "^7.1.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-git-describe": "^1.0.0",
|
||||
"vue-template-compiler": "^2.6.10"
|
||||
},
|
||||
"license": "ISC"
|
||||
|
|
|
@ -1,18 +1,22 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<link rel="stylesheet" href="//cdn.materialdesignicons.com/2.0.46/css/materialdesignicons.min.css">
|
||||
<title>mcgvue</title>
|
||||
</head>
|
||||
<body>
|
||||
<link rel="stylesheet" href="//cdn.materialdesignicons.com/2.0.46/css/materialdesignicons.min.css">
|
||||
<title>MLPCARDGAME</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<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>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -1,14 +1,14 @@
|
|||
{
|
||||
"name": "mcgvue",
|
||||
"short_name": "mcgvue",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./img/icons/android-chrome-192x192.png",
|
||||
"name": "MLPCARDGAME",
|
||||
"short_name": "mcg",
|
||||
"description": "MLP:CCG simulator",
|
||||
"icons": [{
|
||||
"src": "./images/icons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./img/icons/android-chrome-512x512.png",
|
||||
"src": "./images/icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
|
|
16
src/App.vue
|
@ -1,6 +1,5 @@
|
|||
<template>
|
||||
<main v-if="loaded">
|
||||
<TopBar v-if="!isFullscreen" />
|
||||
<router-view />
|
||||
</main>
|
||||
<main class="loading-box" v-else>
|
||||
|
@ -9,6 +8,10 @@
|
|||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
main {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
main.loading-box {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
|
@ -25,15 +28,11 @@ h1.loading-message {
|
|||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-property-decorator";
|
||||
import { Action, Getter } from "vuex-class";
|
||||
import TopBar from "@/components/Navigation/TopBar.vue";
|
||||
import { loadSets } from "@/mlpccg/set";
|
||||
import { AppState } from "./store/types";
|
||||
import { getCards } from "./mlpccg/database";
|
||||
import { AppState } from "@/store/types";
|
||||
import { refreshCardSource, loadSets, getCards } from "@/mlpccg";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
TopBar
|
||||
}
|
||||
components: {}
|
||||
})
|
||||
export default class App extends Vue {
|
||||
@Action showLoading!: (msg: string) => void;
|
||||
|
@ -54,6 +53,7 @@ export default class App extends Vue {
|
|||
private async loadCards() {
|
||||
this.showLoading("Downloading data for all sets");
|
||||
await loadSets();
|
||||
await refreshCardSource();
|
||||
this.hideLoading();
|
||||
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
|
||||
|
||||
$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;
|
||||
$render-mode: optimizeLegibility !default;
|
||||
|
||||
|
@ -53,11 +65,11 @@ $gap: 32px !default;
|
|||
// 960, 1152, and 1344 have been chosen because they are divisible by both 12 and 16
|
||||
$tablet: 769px !default;
|
||||
// 960px container + 4rem
|
||||
$desktop: 960px + (2 * $gap) !default;
|
||||
$desktop: 960px+(2 * $gap) !default;
|
||||
// 1152px container + 4rem
|
||||
$widescreen: 1152px + (2 * $gap) !default;
|
||||
$widescreen: 1152px+(2 * $gap) !default;
|
||||
// 1344px container + 4rem;
|
||||
$fullhd: 1344px + (2 * $gap) !default;
|
||||
$fullhd: 1344px+(2 * $gap) !default;
|
||||
|
||||
// Miscellaneous
|
||||
|
||||
|
@ -72,7 +84,6 @@ $speed: 86ms !default;
|
|||
|
||||
$variable-columns: true !default;
|
||||
|
||||
|
||||
// The default Bulma derived variables are declared below
|
||||
|
||||
$primary: $turquoise !default;
|
||||
|
@ -150,3 +161,10 @@ $size-small: $size-7 !default;
|
|||
$size-normal: $size-6 !default;
|
||||
$size-medium: $size-5 !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";
|
||||
@import "~buefy/src/scss/buefy";
|
||||
@import "dark";
|
||||
@import url('https://fonts.googleapis.com/css?family=Merriweather:300,400,400i,700&display=swap');
|
||||
|
||||
html {
|
||||
scrollbar-color: #404245 #2f3132;
|
||||
|
@ -12,5 +14,28 @@ html {
|
|||
}
|
||||
|
||||
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 "./plugins/axios";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import store from "./store";
|
||||
import "./registerServiceWorker";
|
||||
import Buefy from "buefy";
|
||||
import "./assets/scss/app.scss";
|
||||
import { initDB } from "./mlpccg";
|
||||
|
||||
Vue.use(Buefy);
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
|
||||
initDB();
|
||||
|
||||
new Vue({
|
||||
router,
|
||||
store,
|
||||
|
|
|
@ -1,5 +1,71 @@
|
|||
const imgBaseURL = "https://mcg.zyg.ovh/images/cards/";
|
||||
import { Card } from "./types";
|
||||
|
||||
export function cardImageURL(cardid: string): string {
|
||||
return `${imgBaseURL}${cardid}.webp`;
|
||||
export function cardFullName(card: Card): string {
|
||||
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 { Card, CardFilter } from "./types";
|
||||
import { Card, CardFilter, StoredImage } from "./types";
|
||||
import { cardFullName } from "./card";
|
||||
|
||||
class CardDatabase extends Dexie {
|
||||
public cards: Dexie.Table<Card, string>;
|
||||
public images: Dexie.Table<StoredImage, string>;
|
||||
|
||||
public constructor() {
|
||||
super("CardDatabase");
|
||||
this.version(1).stores({
|
||||
cards: "ID,Set,Type,Cost,Power"
|
||||
cards: "ID,Set,Type,Cost,Power",
|
||||
images: "id"
|
||||
});
|
||||
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) {
|
||||
if (Database == null) {
|
||||
throw new Error("Database was not initialized, init with 'initDB()'");
|
||||
}
|
||||
let table = Database.cards;
|
||||
// Get best IDB index
|
||||
let query: Dexie.Collection<Card, string>;
|
||||
|
@ -43,11 +64,10 @@ export async function getCards(filter: CardFilter) {
|
|||
}
|
||||
}
|
||||
|
||||
return await query
|
||||
.filter(x => {
|
||||
const results = query.filter(x => {
|
||||
if (filter.Name) {
|
||||
if (
|
||||
!`${x.Name}, ${x.Subname}`
|
||||
!cardFullName(x)
|
||||
.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) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (filter.Powers && filter.Powers.length > 0) {
|
||||
if (
|
||||
typeof x.Power === "undefined" ||
|
||||
!filter.Powers.includes(x.Power)
|
||||
) {
|
||||
if (typeof x.Power === "undefined" || !filter.Powers.includes(x.Power)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -132,6 +158,18 @@ export async function getCards(filter: CardFilter) {
|
|||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
|
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 { Database } from "./database";
|
||||
import axios from "axios";
|
||||
|
||||
const baseURL = "https://mcg.zyg.ovh/setdata/";
|
||||
const allSets = [
|
||||
export const allSets = [
|
||||
"PR",
|
||||
"CN",
|
||||
"RR",
|
||||
|
@ -18,7 +19,27 @@ const allSets = [
|
|||
"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() {
|
||||
if (Database == null) {
|
||||
throw new Error("Database was not initialized, init with 'initDB()'");
|
||||
}
|
||||
const itemcount = await Database.cards.count();
|
||||
if (itemcount > 100) {
|
||||
// DB already filled, exit early
|
||||
|
@ -27,18 +48,19 @@ export async function loadSets() {
|
|||
const sets = await Promise.all(allSets.map(set => downloadSet(set)));
|
||||
await Promise.all(
|
||||
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);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function downloadSet(setid: string): Promise<SetFile> {
|
||||
const setfile = await fetch(`${baseURL}${setid.toLowerCase()}.json`);
|
||||
const setdata: SetFile = await setfile.json();
|
||||
setdata.Cards = setdata.Cards.map(c => {
|
||||
const setdata = await axios(`${baseURL}${setid.toLowerCase()}.json`);
|
||||
setdata.data.Cards = (setdata.data as SetFile).Cards.map(c => {
|
||||
c.Set = setid;
|
||||
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 interface StoredImage {
|
||||
id: string;
|
||||
image: Blob;
|
||||
}
|
||||
|
||||
export interface SetFile {
|
||||
Name: string;
|
||||
Cards: Card[];
|
||||
|
@ -38,3 +43,9 @@ export interface CardFilter {
|
|||
Powers?: number[];
|
||||
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 Vue from "vue";
|
||||
|
||||
import { NetworkMessage, PasswordResponse, PeerMetadata, RoomInfo } from "./types";
|
||||
|
||||
export abstract class Client extends EventEmitter {
|
||||
public metadata: PeerMetadata;
|
||||
|
@ -23,13 +20,19 @@ export abstract class Client extends EventEmitter {
|
|||
case "room-info":
|
||||
this.roomInfo = data.room;
|
||||
this.players = data.players;
|
||||
this.emit("handshake");
|
||||
break;
|
||||
// Someone changed name (or was forced to)
|
||||
case "rename":
|
||||
if (data.oldname == this.metadata.name) {
|
||||
// We got a name change!
|
||||
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);
|
||||
if (idx < 0) {
|
||||
// Weird
|
||||
|
@ -39,7 +42,7 @@ export abstract class Client extends EventEmitter {
|
|||
this.players.push(data.newname);
|
||||
break;
|
||||
}
|
||||
this.players[idx] = data.newname;
|
||||
Vue.set(this.players, idx, data.newname);
|
||||
}
|
||||
this.emit("rename", data.oldname, data.newname);
|
||||
break;
|
||||
|
@ -48,7 +51,7 @@ export abstract class Client extends EventEmitter {
|
|||
this.players.push(data.name);
|
||||
this.emit("player-joined", data.name);
|
||||
break;
|
||||
case "player-left":
|
||||
case "player-left": {
|
||||
let idx = this.players.indexOf(data.name);
|
||||
if (idx < 0) {
|
||||
// Weird
|
||||
|
@ -57,9 +60,13 @@ export abstract class Client extends EventEmitter {
|
|||
);
|
||||
break;
|
||||
}
|
||||
this.emit("player-left", data.name);
|
||||
this.players.splice(idx, 1);
|
||||
break;
|
||||
}
|
||||
case "password-req":
|
||||
this.emit("password-required");
|
||||
break;
|
||||
default:
|
||||
// For most cases, we can just use the kind as event type
|
||||
this.emit(data.kind, data);
|
||||
|
|
|
@ -25,6 +25,12 @@ export class PeerClient extends Client {
|
|||
this.connection.on("open", () => {
|
||||
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._received(data);
|
||||
});
|
||||
|
@ -34,6 +40,10 @@ export class PeerClient extends Client {
|
|||
return this.metadata.name;
|
||||
}
|
||||
|
||||
public get id(): string {
|
||||
return this.peer.id;
|
||||
}
|
||||
|
||||
public send<T extends NetworkMessage>(data: T) {
|
||||
if (!this.connection) {
|
||||
throw new Error("Client is not connected to a server");
|
||||
|
|
|
@ -1,23 +1,24 @@
|
|||
import EventEmitter from "eventemitter3";
|
||||
import Peer, { DataConnection } from "peerjs";
|
||||
|
||||
import { LocalClient } from ".";
|
||||
import {
|
||||
RoomInfo,
|
||||
PasswordRequest,
|
||||
Room,
|
||||
AckMessage,
|
||||
ChatMessage,
|
||||
ErrorMessage,
|
||||
JoinMessage,
|
||||
LeaveMessage,
|
||||
NetworkMessage,
|
||||
NetworkPlayer,
|
||||
PasswordRequest,
|
||||
PasswordResponse,
|
||||
PeerMetadata,
|
||||
JoinMessage,
|
||||
RoomInfoMessage,
|
||||
Player,
|
||||
NetworkMessage,
|
||||
RenameMessage,
|
||||
LeaveMessage,
|
||||
NetworkPlayer,
|
||||
AckMessage,
|
||||
ChatMessage
|
||||
Room,
|
||||
RoomInfo,
|
||||
RoomInfoMessage,
|
||||
} from "./types";
|
||||
import { LocalClient } from ".";
|
||||
import EventEmitter from "eventemitter3";
|
||||
|
||||
// Increment name, add number at the end if not present
|
||||
// Examples:
|
||||
|
@ -34,6 +35,12 @@ function nextName(name: string): string {
|
|||
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 {
|
||||
protected peer: Peer;
|
||||
private room: Room;
|
||||
|
@ -64,17 +71,21 @@ export class PeerServer extends EventEmitter {
|
|||
|
||||
// Setup 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);
|
||||
this.emit("open", id);
|
||||
});
|
||||
this.peer.on("connection", conn => {
|
||||
this._connection(conn);
|
||||
});
|
||||
}
|
||||
|
||||
private _connection(conn: DataConnection) {
|
||||
private async _connection(conn: DataConnection) {
|
||||
const metadata = conn.metadata as PeerMetadata;
|
||||
|
||||
// Wait for connection to be open
|
||||
await connectionOpen(conn);
|
||||
|
||||
let player: NetworkPlayer = {
|
||||
kind: "remote",
|
||||
name: metadata.name,
|
||||
|
@ -140,6 +151,12 @@ export class PeerServer extends EventEmitter {
|
|||
|
||||
private addPlayer(player: NetworkPlayer) {
|
||||
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;
|
||||
|
||||
// Start listening for new messages
|
||||
|
@ -148,6 +165,10 @@ export class PeerServer extends EventEmitter {
|
|||
this._received.bind(this, this.room.players[playerName])
|
||||
);
|
||||
|
||||
player.conn.on("error", err => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Send the player info about the room
|
||||
this.send<RoomInfoMessage>(player, {
|
||||
kind: "room-info",
|
||||
|
@ -155,7 +176,7 @@ export class PeerServer extends EventEmitter {
|
|||
...this.room.info,
|
||||
password: ""
|
||||
},
|
||||
players: Object.keys(this.room.players)
|
||||
players
|
||||
});
|
||||
|
||||
// Notify other players
|
||||
|
@ -172,6 +193,9 @@ export class PeerServer extends EventEmitter {
|
|||
// Close connection with player
|
||||
player.conn.close();
|
||||
|
||||
// Remove player from player list
|
||||
delete this.room.players[player.name];
|
||||
|
||||
// Notify other players
|
||||
this.broadcast<LeaveMessage>({
|
||||
kind: "player-left",
|
||||
|
@ -189,16 +213,30 @@ export class PeerServer extends EventEmitter {
|
|||
this.broadcast(data);
|
||||
} else {
|
||||
// Player is telling someone specifically
|
||||
if (data.to in this.players) {
|
||||
this.send<ChatMessage>(this.players[data.to], data);
|
||||
} else {
|
||||
if (!(data.to in this.players)) {
|
||||
this.send<ErrorMessage>(player, {
|
||||
kind: "error",
|
||||
error: `player not found: ${data.to}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.send<ChatMessage>(this.players[data.to], data);
|
||||
}
|
||||
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!
|
||||
case "leave-req":
|
||||
// If we're leaving, end the server
|
||||
|
@ -234,6 +272,10 @@ export class PeerServer extends EventEmitter {
|
|||
this.send<T>(player, message);
|
||||
}
|
||||
}
|
||||
|
||||
public get id(): string {
|
||||
return this.peer.id;
|
||||
}
|
||||
}
|
||||
|
||||
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 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);
|
||||
|
||||
|
@ -21,7 +21,10 @@ export default new Router({
|
|||
{
|
||||
path: "/build",
|
||||
name: "deck-editor",
|
||||
component: DeckBuilder
|
||||
component: DeckBuilder,
|
||||
meta: {
|
||||
topnav: "Deck Builder"
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "/game",
|
||||
|
@ -36,12 +39,26 @@ export default new Router({
|
|||
{
|
||||
path: "/lobby",
|
||||
name: "lobby",
|
||||
component: Lobby
|
||||
component: Lobby,
|
||||
meta: {
|
||||
topnav: "Lobby"
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "/room",
|
||||
name: "room",
|
||||
component: RoomView
|
||||
path: "/join/:id",
|
||||
name: "lobby-join",
|
||||
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 getters from "./getters";
|
||||
|
||||
import network from "./network";
|
||||
import draft from "./draft";
|
||||
|
||||
const store: StoreOptions<AppState> = {
|
||||
state: {
|
||||
loaded: false,
|
||||
|
@ -17,7 +20,10 @@ const store: StoreOptions<AppState> = {
|
|||
actions,
|
||||
mutations,
|
||||
getters,
|
||||
modules: {}
|
||||
modules: {
|
||||
network,
|
||||
draft
|
||||
}
|
||||
};
|
||||
|
||||
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 "./MockPeer";
|
||||
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: {
|
||||
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 { EventHook } from "@/testing/EventHook";
|
||||
|
||||
const sampleRoom = () => ({
|
||||
max_players: 3,
|
||||
|
|
|
@ -1,14 +1,479 @@
|
|||
<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>
|
||||
|
||||
<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">
|
||||
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({
|
||||
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>
|
||||
|
|
|
@ -1,23 +1,118 @@
|
|||
<template>
|
||||
<section class="draftview">
|
||||
<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 class="pool"><b>Card pool</b></section>
|
||||
<section class="cardlist"><b>Cards</b></section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/assets/scss/_variables.scss";
|
||||
|
||||
$player-not-picked: $red;
|
||||
$player-picked: $green;
|
||||
$player-me: $purple;
|
||||
$border-opacity: 0.6;
|
||||
|
||||
.draftview {
|
||||
background: url("../assets/images/backgrounds/draftbg.webp") center;
|
||||
display: grid;
|
||||
height: 100vh;
|
||||
grid-gap: 10px;
|
||||
grid-template-columns: 200px 1fr 250px;
|
||||
section {
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(200px, 1fr) 3fr minmax(250px, 1fr);
|
||||
& > section {
|
||||
padding: 10px 20px;
|
||||
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) {
|
||||
|
@ -54,9 +149,95 @@
|
|||
|
||||
<script lang="ts">
|
||||
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({
|
||||
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>
|
||||
|
|
|
@ -1,12 +1,140 @@
|
|||
<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>
|
||||
|
||||
<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">
|
||||
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({
|
||||
components: {}
|
||||
})
|
||||
export default class Home extends Vue {}
|
||||
export default class Home extends Vue {
|
||||
private projectName: string = "MLPCARDGAME";
|
||||
private projectVersion: string = versionString;
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,14 +1,374 @@
|
|||
<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>
|
||||
|
||||
<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">
|
||||
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({
|
||||
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>
|
||||
|
|
|
@ -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);
|
||||
}
|