47 Commits
app ... main

Author SHA1 Message Date
cd4754901c Tweak the wording 2025-02-19 19:51:26 -08:00
2a8ee97e76 Update the readme 2025-02-19 19:49:58 -08:00
64354f93f8 remove cards 2025-02-19 19:45:12 -08:00
83b42776d1 Merge branch 'drawer' 2025-02-19 19:41:13 -08:00
54a405b4b9 add some cards 2025-01-11 09:49:23 -08:00
6c3194c256 Add a few more cards 2025-01-08 21:47:44 -08:00
dc49eb961f account for preview when sizing the title 2025-01-08 19:19:21 -08:00
055e42ecf7 fix sizing 2025-01-08 19:14:01 -08:00
4d318d20ee add some cards and fix some coloring 2025-01-08 19:08:40 -08:00
7a752a92cb Wait for fonts to load 2025-01-07 23:07:20 -08:00
03fff8576e cards 2025-01-07 22:25:44 -08:00
6827b966fd Add sun 2025-01-07 22:17:05 -08:00
68c4222b3b add duration to sample card 2025-01-07 22:00:02 -08:00
1e90062f6d Add split card coloring 2025-01-07 20:18:42 -08:00
ff5c543147 Allow big symbols 2025-01-07 20:02:50 -08:00
52db63d395 rename a sample card 2025-01-07 08:20:42 -08:00
e786943d16 Add hr 2025-01-07 08:19:47 -08:00
3adf3bf76d add vp symbols 2025-01-07 08:10:47 -08:00
6213eda240 Tweaks 2025-01-06 21:11:31 -08:00
7f4268f960 Add debt and potion 2025-01-06 21:06:13 -08:00
a5979647fc Add bolding for plusses 2025-01-06 23:51:51 -05:00
0e9661f146 Add image drawing 2025-01-06 23:34:41 -05:00
b0249f8baf draw types 2025-01-06 23:01:01 -05:00
9cef34b976 small tweaks 2025-01-06 22:28:57 -05:00
28f214fb51 price -> cost 2025-01-06 22:14:58 -05:00
0099624165 more work 2025-01-06 22:13:53 -05:00
4e79fd38a1 use dominiontext framework 2025-01-06 21:12:28 -05:00
8e7bcc185c start on making a text framework 2025-01-06 12:26:18 -05:00
1e6e336f73 Add first-pass colors to the card types 2025-01-06 00:12:39 -05:00
f497057dae improve some drawing 2025-01-05 23:55:22 -05:00
3bb0308949 Move some files so that deno task dev works 2025-01-05 22:17:30 -05:00
0da7c3ab23 start on text 2025-01-05 10:12:27 -05:00
c196646955 progress! 2025-01-04 22:32:17 -05:00
3c3ee0565a update cards list 2025-01-04 21:35:37 -05:00
98a2ce93fe Add a card 2024-12-31 11:56:38 -05:00
7236e9f04e wip 2024-12-31 11:53:45 -05:00
b81144153b Switch to deno 2024-12-29 23:00:38 -05:00
84bc6d79f5 Some more cards 2024-12-29 22:29:31 -05:00
c698ac3499 add 2 more night cards 2024-12-28 23:29:57 -05:00
e4484b6873 add 2 night cards 2024-12-28 23:05:27 -05:00
f330dbde33 add secret society 2024-12-27 23:45:50 -05:00
bb2403adad add credit 2024-12-27 20:49:44 -05:00
f7e4116b42 Add some more cards 2024-12-27 20:48:20 -05:00
50f27010f0 Add 3 cards 2024-12-27 08:54:21 -05:00
5ec05e3db7 wip 2023-12-27 11:37:37 -08:00
5a837cc373 brainstorming typescript 2023-12-18 21:58:27 -08:00
317d2e6de3 add shardofhonor reference file 2023-12-18 21:07:52 -08:00
80 changed files with 2686 additions and 29811 deletions

26
.gitignore vendored
View File

@ -1,23 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
node_modules
# dependencies
/node_modules
/.pnp
.pnp.js
# dotenv environment variable files
.env
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
**/dist/**/*
static/dist

View File

@ -1,46 +1,5 @@
# Getting Started with Create React App
# Dominionator
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
A web-app for making [Dominion](https://www.riograndegames.com/games/dominion/) fan-cards. Inspired by [shardofhonor's dominion-card-generator](https://github.com/shardofhonor/dominion-card-generator), but trying to be more modern and maintainable with React and Typescript. Currently it's not much of a web-app since there are no inputs to edit the cards, but you can mess with the hardcoded array in `src/cards.ts` to render arbitrary cards.
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
To run it, clone the repo and run `deno task dev`.

42
deno.json Normal file
View File

@ -0,0 +1,42 @@
{
"lock": false,
"tasks": {
"dev": "deno run -A tools/dev.ts",
"build": "deno run -A tools/build.ts",
"build:watch": "deno run --watch=src -A tools/build.ts",
"serve": "deno run -A src/server/index.ts",
"serve:watch": "deno run --watch=src -A src/server/index.ts"
},
"lint": {
"rules": {
"tags": [
"recommended"
]
}
},
"exclude": [
"dist"
],
"imports": {
"react/": "https://esm.sh/react@18.3.1/",
"react-dom/": "https://esm.sh/react-dom@18.3.1/client/",
"react": "https://esm.sh/react@18.3.1",
"react-dom": "https://esm.sh/react-dom@18.3.1/client",
"canvas": "https://esm.sh/canvas@3.0.0"
},
"compilerOptions": {
"lib": ["deno.ns", "DOM"],
"jsx": "react-jsx",
"jsxImportSource": "react",
"strict": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"allowUnusedLabels": false
},
"fmt": {
"useTabs": true
}
}

29401
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,48 +0,0 @@
{
"name": "dominionator",
"version": "0.1.0",
"private": true,
"license": "UNLICENSED",
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"repository": {
"type": "git",
"url": "https://git.playbox.link/dylan/dominionator.git"
},
"dependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.38",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -1,25 +0,0 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

1230
reference.js Normal file

File diff suppressed because it is too large Load Diff

3
root.ts Normal file
View File

@ -0,0 +1,3 @@
import { dirname, fromFileUrl } from "jsr:@std/path";
export const projectRootDir = dirname(fromFileUrl(import.meta.url));

View File

@ -1,38 +0,0 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@ -1,9 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@ -1,23 +0,0 @@
import React from 'react';
import logo from './logo.svg';
import './App.css';
import { Card } from './Card';
function App() {
return (
<div className="App">
<Card card={{
name: "My Card",
v: 1,
types: ["Action"],
cost: "5",
preview: "",
text: "+3 Cards",
art: "",
artCredit: "",
}} />
</div>
);
}
export default App;

View File

@ -1,43 +0,0 @@
import React from "react";
type CardType =
| "Action"
| "Treasure"
| "Victory"
| "Curse"
| "Reaction"
| "Duration"
| "Reserve"
| "Night"
type CardData = {
name: string,
text: string,
cost: string,
preview: string,
types: Array<CardType>,
art: string,
artCredit: string,
v: number,
}
const drawCard = (canvas: HTMLCanvasElement, card: CardData) => {
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Could not get the context of the canvas");
}
context.clearRect(0, 0, canvas.width, canvas.height);
context.font = "88px myTitle"
context.fillText(card.name, 100, 100);
}
export const Card = (props: {card: CardData}) => {
const {
card
} = props;
return <canvas className="card" width={1403} height={2151} ref={(el) => {
if (el) {
drawCard(el, card);
}
}}></canvas>
}

27
src/cards.ts Normal file
View File

@ -0,0 +1,27 @@
import {
DominionCard,
TYPE_ACTION,
// TYPE_DURATION,
// TYPE_NIGHT,
// TYPE_TREASURE,
// TYPE_VICTORY,
} from "./types.ts";
const expansionIcon = "";
const author = "Dylan";
const _sampleCard: DominionCard = {
orientation: "card",
title: "Sample",
description: "",
types: [TYPE_ACTION],
image: "",
artist: "",
author,
version: "0.1",
cost: "$",
preview: "",
expansionIcon,
};
export const cards: DominionCard[] = [_sampleCard, _sampleCard];

10
src/client/App.tsx Normal file
View File

@ -0,0 +1,10 @@
import { cards } from "../cards.ts";
import { Card } from "./Card.tsx";
export const App = () => {
return <div>
{cards.map((card) => {
return <Card key={`${card.title}`} card={card}/>
})}
</div>;
};

28
src/client/Card.tsx Normal file
View File

@ -0,0 +1,28 @@
import { drawCard, loadImages, loadFonts } from "../draw.ts";
import { DominionCard } from "../types.ts";
const sizeMap = {
card: {
width: 1403,
height: 2151,
},
landscape: {
width: 2151,
height: 1403,
}
}
export const Card = (props: {card: DominionCard}) => {
const {card} = props;
const {width, height} = sizeMap[card.orientation];
return <canvas style={{width: "2.5in"}} width={width} height={height} ref={async (canvasElement) => {
if (canvasElement) {
const context = canvasElement.getContext("2d");
if (context) {
await loadFonts();
await loadImages();
await drawCard(context, card);
}
}
}}></canvas>
}

14
src/client/index.tsx Normal file
View File

@ -0,0 +1,14 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom";
import { App } from "./App.tsx";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw Error("No root element to attach react to.");
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
);

5
src/colorhelper.ts Normal file
View File

@ -0,0 +1,5 @@
import parseColor1 from "npm:parse-color";
export const parseColor = (c: string): { rgb: [number, number, number] } => {
return parseColor1(c);
};

480
src/dominiontext.ts Normal file
View File

@ -0,0 +1,480 @@
import { getImage } from "./draw.ts";
import { parseFont, stringifyFont } from "./fonthelper.ts";
export type Piece =
| { type: "text"; text: string; isBold?: boolean; isItalic?: boolean }
| { type: "space" }
| { type: "break" }
| { type: "hr" }
| {
type: "symbol";
symbol: "coin" | "debt" | "potion" | "vp" | "vp-token" | "sun";
isBig?: boolean;
prefix?: string;
text: string;
textColor: string;
};
type PromiseOr<T> = T | Promise<T>;
type PieceMeasure = {
type: "content" | "space" | "break";
width: number;
ascent: number;
descent: number;
};
type Line = {
pieces: {
piece: Piece;
measure: PieceMeasure;
xOffset: number;
}[];
width: number;
ascent: number;
descent: number;
};
type PieceTools = {
measurePiece: (
context: CanvasRenderingContext2D,
piece: Piece
) => PromiseOr<PieceMeasure>;
renderPiece: (
context: CanvasRenderingContext2D,
piece: Piece,
x: number,
y: number
) => PromiseOr<void>;
};
type PieceDef<T extends Piece["type"], M extends PieceMeasure> = {
type: T;
measure(
context: CanvasRenderingContext2D,
piece: Piece & { type: T },
tools: PieceTools
): PromiseOr<M>;
render(
context: CanvasRenderingContext2D,
piece: Piece & { type: T },
x: number,
y: number,
measure: NoInfer<M>,
tools: PieceTools
): PromiseOr<void>;
};
const pieceDef = <T extends Piece["type"], M extends PieceMeasure>(
def: PieceDef<T, M>
) => {
return def;
};
const textPiece = pieceDef({
type: "text",
measure(context, piece) {
context.save();
const fontInfo = parseFont(context.font);
if (piece.isBold) {
fontInfo.weight = "bold";
}
if (piece.isItalic) {
fontInfo.style = "italic";
}
const font = stringifyFont(fontInfo);
context.font = font;
const metrics = context.measureText(piece.text);
context.restore();
return {
type: "content",
width: metrics.width,
ascent: metrics.fontBoundingBoxAscent,
descent: metrics.fontBoundingBoxDescent,
font,
};
},
render(context, piece, x, y, measure) {
context.save();
context.font = measure.font;
context.fillText(piece.text, x, y);
context.restore();
},
});
const spacePiece = pieceDef({
type: "space",
measure(context, _piece) {
const metrics = context.measureText(" ");
return {
type: "space",
width: metrics.width,
ascent: metrics.fontBoundingBoxAscent,
descent: metrics.fontBoundingBoxDescent,
};
},
render() {},
});
const breakPiece = pieceDef({
type: "break",
measure(context, _piece) {
const metrics = context.measureText(" ");
return {
type: "break",
width: 0,
ascent: metrics.fontBoundingBoxAscent / 3,
descent: metrics.fontBoundingBoxDescent / 3,
};
},
render() {},
});
const hrPiece = pieceDef({
type: "hr",
measure(context, _piece) {
const metrics = context.measureText(" ");
const h =
(metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent) /
3;
return {
type: "content",
width: 750,
ascent: h / 2,
descent: h / 2,
};
},
render(context, _piece, x, y, measure) {
context.save();
context.beginPath();
context.moveTo(x, y);
context.lineTo(x + measure.width, y);
context.lineWidth = 8;
context.stroke();
context.restore();
},
});
const symbolPiece = pieceDef({
type: "symbol",
measure(context, piece) {
context.save();
const metrics = context.measureText(" ");
const height =
metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent;
const prefixMetrics = context.measureText(piece.prefix ?? "");
const coinImage = getImage(piece.symbol);
context.restore();
const { isBig } = piece;
const scale = isBig ? 2.5 : 1;
return {
type: "content",
width:
scale *
(prefixMetrics.width +
coinImage.width * (height / coinImage.height)),
ascent: scale * metrics.fontBoundingBoxAscent,
descent: scale * metrics.fontBoundingBoxDescent,
prefixWidth: scale * prefixMetrics.width,
scale,
};
},
render(context, piece, x, y, measure) {
if (piece.isBig) {
console.log("big", piece, measure);
}
context.save();
// context.fillStyle = "yellow";
const height = measure.ascent + measure.descent;
// context.fillRect(x, y - measure.ascent, measure.width, height);
context.drawImage(
getImage(piece.symbol),
x + measure.prefixWidth,
y - measure.ascent,
measure.width - measure.prefixWidth,
height
);
context.save();
const prefixFontInfo = parseFont(context.font);
prefixFontInfo.weight = "bold";
prefixFontInfo.size =
parseInt(prefixFontInfo.size.toString()) * measure.scale;
const prefixFont = stringifyFont(prefixFontInfo);
context.font = prefixFont;
context.fillText(piece.prefix ?? "", x, y);
context.restore();
const fontInfo = parseFont(context.font);
fontInfo.family = ["DominionSpecial"];
fontInfo.weight = "bold";
fontInfo.size =
parseInt(fontInfo.size.toString()) * 1.2 * measure.scale;
const font = stringifyFont(fontInfo);
context.font = font;
context.fillStyle = piece.textColor;
context.textAlign = "center";
context.fillText(
piece.text,
x + measure.prefixWidth + (measure.width - measure.prefixWidth) / 2,
y
);
context.restore();
},
});
const pieceDefs = [textPiece, spacePiece, breakPiece, symbolPiece, hrPiece];
// deno-lint-ignore no-explicit-any
const tools: PieceTools = {} as any;
const measurePiece = (context: CanvasRenderingContext2D, piece: Piece) => {
const def = pieceDefs.find((def) => def.type === piece.type)!;
// deno-lint-ignore no-explicit-any
return def.measure(context, piece as any, tools);
};
const renderPiece = (
context: CanvasRenderingContext2D,
piece: Piece,
x: number,
y: number
) => {
const def = pieceDefs.find((def) => def.type === piece.type)!;
// deno-lint-ignore no-explicit-any
const measure = def.measure(context, piece as any, tools);
// deno-lint-ignore no-explicit-any
return def.render(context, piece as any, x, y, measure as any, tools);
};
tools.measurePiece = measurePiece;
tools.renderPiece = renderPiece;
type DominionFont = {
font: "text" | "title";
size: number;
isBold: boolean;
isItalic: boolean;
};
type PieceWithInfo = {
piece: Piece;
measure: PieceMeasure;
};
export const measureDominionText = async (
context: CanvasRenderingContext2D,
pieces: Piece[],
maxWidth = Infinity
) => {
const data: PieceWithInfo[] = await Promise.all(
pieces.map(async (piece) => ({
piece,
measure: await measurePiece(context, piece),
}))
);
let lines: Line[] = [{ pieces: [], width: 0, ascent: 0, descent: 0 }];
for (const pieceInfo of data) {
const line = lines[lines.length - 1]!;
if (pieceInfo.measure.type === "break") {
line.pieces.push({ ...pieceInfo, xOffset: line.width });
line.width += pieceInfo.measure.width;
line.ascent = Math.max(line.ascent, pieceInfo.measure.ascent);
line.descent = Math.max(line.descent, pieceInfo.measure.descent);
lines.push({ pieces: [], width: 0, ascent: 0, descent: 0 });
} else {
if (line.width + pieceInfo.measure.width > maxWidth) {
lines.push({
pieces: [{ ...pieceInfo, xOffset: 0 }],
width: pieceInfo.measure.width,
ascent: pieceInfo.measure.ascent,
descent: pieceInfo.measure.descent,
});
} else {
line.pieces.push({
...pieceInfo,
xOffset: line.width,
});
line.width += pieceInfo.measure.width;
line.ascent = Math.max(line.ascent, pieceInfo.measure.ascent);
line.descent = Math.max(
line.descent,
pieceInfo.measure.descent
);
}
}
}
lines = lines.map((line) => {
while (
line.pieces[line.pieces.length - 1] &&
line.pieces[line.pieces.length - 1]!.measure.type === "space"
) {
line.pieces = line.pieces.slice(0, -1);
}
line.width = line.pieces
.map((piece) => piece.measure.width)
.reduce((a, b) => a + b, 0);
return line;
});
return {
lines,
width: Math.max(...lines.map((line) => line.width)),
height: lines
.map((line) => line.ascent + line.descent)
.reduce((a, b) => a + b, 0),
};
};
const debug = false;
export const renderDominionText = async (
context: CanvasRenderingContext2D,
pieces: Piece[],
x: number,
y: number,
maxWidth = Infinity
) => {
const { lines, height } = await measureDominionText(
context,
pieces,
maxWidth
);
let yOffset = 0;
for (const line of lines) {
yOffset += line.ascent;
for (const { piece, xOffset } of line.pieces) {
await renderPiece(
context,
piece,
x - line.width / 2 + xOffset,
y - height / 2 + yOffset
);
if (debug) {
context.save();
context.strokeStyle = "blue";
context.lineWidth = 5;
const pieceMeasure = await measurePiece(context, piece);
context.strokeRect(
x - line.width / 2 + xOffset,
y - height / 2 - line.ascent + yOffset,
pieceMeasure.width,
pieceMeasure.ascent + pieceMeasure.descent
);
context.strokeStyle = "red";
context.beginPath();
context.moveTo(
x - line.width / 2 + xOffset - 5,
y - height / 2 + yOffset
);
context.lineTo(
x - line.width / 2 + xOffset + 5,
y - height / 2 + yOffset
);
context.stroke();
context.restore();
}
}
yOffset += line.descent;
}
};
export const parse = (
text: string,
options?: { isDescription: boolean }
): Piece[] => {
const { isDescription = false } = options ?? {};
const pieces: Piece[] = [];
const symbolMap = {
"$": { symbol: "coin", textColor: "black" },
"@": { symbol: "debt", textColor: "white" },
"^": { symbol: "potion", textColor: "white" },
"%": { symbol: "vp", textColor: "white" },
"#": { symbol: "vp-token", textColor: "black" },
"*": { symbol: "sun", textColor: "black" },
} as const;
for (let i = 0; i < text.length; i++) {
const char = text[i]!;
if (char === " ") {
pieces.push({ type: "space" });
} else if (char === "\n") {
pieces.push({ type: "break" });
} else if (char in symbolMap) {
const c = char as keyof typeof symbolMap;
const end = text.slice(i).match(new RegExp(`\\${c}[^ \n.,;]*`))![0]
.length;
const isBig =
isDescription &&
["\n", undefined].includes(text[i - 1]) &&
["\n", undefined].includes(text[i + end]);
pieces.push({
type: "symbol",
...symbolMap[c],
text: text.slice(i + 1, i + end),
isBig,
});
i += end - 1;
} else if (char === "+") {
const match = text.slice(i).match(/\+\d*( \w+)?/);
if (match) {
const end = match[0].length;
pieces.push({
type: "text",
isBold: true,
text: text.slice(i, i + end),
});
i += end - 1;
} else {
pieces.push({
type: "text",
isBold: true,
text: "+",
});
}
} else if (
char === "-" &&
text[i - 1] === "\n" &&
text[i + 1] === "\n"
) {
pieces.push({ type: "hr" });
} else if (/\d/.test(char)) {
const match = text.slice(i).match(
new RegExp(
`\\d+(${Object.keys(symbolMap)
.map((s) => `\\${s}`)
.join("|")})`
)
);
if (match) {
const end = match[0].length;
const symbolChar = match[1] as keyof typeof symbolMap;
const isBig =
isDescription &&
["\n", undefined].includes(text[i - 1]) &&
["\n", undefined].includes(text[i + end]);
pieces.push({
type: "symbol",
...symbolMap[symbolChar],
prefix: text.slice(i, i + end - 1),
text: "",
isBig,
});
i += end - 1;
} else {
const end = text.slice(i).match(/\d+/)![0].length;
pieces.push({ type: "text", text: text.slice(i, i + end) });
i += end - 1;
}
} else {
const end = text.slice(i).match(
new RegExp(
`[^${Object.keys(symbolMap)
.map((s) => `\\${s}`)
.join("")} \n]+`
)
)![0].length;
pieces.push({ type: "text", text: text.slice(i, i + end) });
i += end - 1;
}
}
return pieces;
};

405
src/draw.ts Normal file
View File

@ -0,0 +1,405 @@
import { parseColor } from "./colorhelper.ts";
import {
measureDominionText,
parse,
renderDominionText,
} from "./dominiontext.ts";
import { DominionCardType, TYPE_ACTION } from "./types.ts";
import { DominionCard } from "./types.ts";
const imageCache: Record<string, HTMLImageElement> = {};
export const loadImage = (
src: string,
key?: string
): Promise<HTMLImageElement | null> => {
return new Promise((resolve) => {
if (key && key in imageCache && imageCache[key]) {
resolve(imageCache[key]);
}
const img = new Image();
img.onload = () => {
if (key) {
imageCache[key] = img;
}
resolve(img);
};
img.onerror = (e) => {
console.log("err", e);
resolve(null);
};
img.src = src;
});
};
const imageList = [
{
key: "card-color-1",
src: "/static/assets/CardColorOne.png",
},
{
key: "card-color-2",
src: "/static/assets/CardColorTwo.png",
},
{
key: "card-color-2-night",
src: "/static/assets/CardColorTwoNight.png",
},
{
key: "card-brown",
src: "/static/assets/CardBrown.png",
},
{
key: "card-gray",
src: "/static/assets/CardGray.png",
},
{
key: "card-description-focus",
src: "/static/assets/DescriptionFocus.png",
},
{
key: "coin",
src: "/static/assets/Coin.png",
},
{
key: "debt",
src: "/static/assets/Debt.png",
},
{
key: "potion",
src: "/static/assets/Potion.png",
},
{
key: "vp",
src: "/static/assets/VP.png",
},
{
key: "vp-token",
src: "/static/assets/VP-Token.png",
},
{
key: "sun",
src: "/static/assets/Sun.png",
},
];
export const loadImages = async () => {
for (const imageInfo of imageList) {
const { key, src } = imageInfo;
await loadImage(src, key);
}
};
export const getImage = (key: string) => {
const image = imageCache[key];
if (!image) {
throw Error(`Tried to get an invalid image ${key}`);
}
return image;
};
export const loadFonts = async () => {
const titleFont = new FontFace(
"DominionTitle",
`local("Trajan Pro Bold"), local("TrajanPro-Bold"), local('Trajan Pro'),
url('https://fonts.cdnfonts.com/s/14928/TrajanPro-Bold.woff') format('woff'),
url('https://shemitz.net/static/dominion3/Trajan%20Pro%20Bold.ttf') format('truetype'),
url('https://dominion.games/fonts/TrajanPro-Bold.otf') format('opentype'),
local("Trajan"),
local("Optimus Princeps"),
url(https://fonts.gstatic.com/s/cinzel/v8/8vIJ7ww63mVu7gt79mT7PkRXMw.woff2) format('woff2')`
);
const specialFont = new FontFace(
"DominionSpecial",
`local("Minion Std Black"), local("MinionStd-Black"), local("Minion Std"), local('Minion Pro'),
url('https://fonts.cdnfonts.com/s/13260/MinionPro-Regular.woff') format('woff'),
url('https://shemitz.net/static/dominion3/MinionStd-Black.otf') format('opentype'),
local("Optimus Princeps"),
url(https://fonts.gstatic.com/s/cinzel/v8/8vIJ7ww63mVu7gt79mT7PkRXMw.woff2) format('woff2')`
);
// deno-lint-ignore no-explicit-any
(document.fonts as any).add(titleFont);
// deno-lint-ignore no-explicit-any
(document.fonts as any).add(specialFont);
await Promise.all([titleFont.load(), specialFont.load()]);
};
export const colorImage = (
image: HTMLImageElement,
color?: string
): HTMLCanvasElement => {
const canvas = document.createElement("canvas");
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext("2d")!;
context.save();
context.drawImage(image, 0, 0);
context.globalCompositeOperation = "multiply";
context.fillStyle = color ?? "white";
context.fillRect(0, 0, canvas.width, canvas.height);
context.globalCompositeOperation = "destination-atop"; // restore transparency
context.drawImage(image, 0, 0);
context.restore();
return canvas;
};
export const drawCard = (
context: CanvasRenderingContext2D,
card: DominionCard
): Promise<void> => {
if (card.orientation === "card") {
return drawStandardCard(context, card);
} else {
return drawLandscapeCard(context, card);
}
};
const _rgbCache: Record<string, { r: number; g: number; b: number }> = {};
const getColorRgb = (c: string): { r: number; g: number; b: number } => {
const { rgb } = parseColor(c);
const [r, g, b] = rgb;
return { r, g, b };
// if (c in _rgbCache) {
// return _rgbCache[c]!;
// }
// const canvas = document.createElement("canvas");
// canvas.width = 10;
// canvas.height = 10;
// const context = canvas.getContext("2d")!;
// context.fillRect(0, 0, 10, 10);
// const data = context.getImageData(5, 5, 1, 1).data;
// console.log(data);
// const [r, g, b] = data;
// const rgb = { r: r!, g: g!, b: b! };
// _rgbCache[c] = rgb;
// return rgb;
};
const getTextColorForBackground = (c: string): string => {
// return "black";
const { r, g, b } = getColorRgb(c);
const avg = (r + g + b) / 3 / 255;
console.log([r, g, b], avg);
return avg > 0.5 ? "black" : "white";
};
const getColors = (
types: DominionCardType[]
): {
primary: string;
secondary: string | null;
description: string | null;
descriptionText: string;
titleText: string;
} => {
const descriptionType =
types.find((t) => t.color?.onConflictDescriptionOnly) ?? null;
const byPriority = [...types]
.filter((type) => type.color && type !== descriptionType)
.sort((a, b) => b.color!.priority - a.color!.priority);
const priority1 = byPriority[0]!;
let primaryType: DominionCardType | null = priority1 ?? null;
let secondaryType = byPriority[1] ?? null;
if (priority1 === TYPE_ACTION) {
const overriders = byPriority.filter((t) => t.color!.overridesAction);
if (overriders.length) {
primaryType = overriders[0] ?? null;
}
if (primaryType === secondaryType) {
secondaryType = byPriority[2] ?? null;
}
}
primaryType = primaryType ?? descriptionType;
const primary = primaryType?.color?.value ?? "white";
const secondary = secondaryType?.color?.value ?? null;
const description = descriptionType?.color?.value ?? null;
const descriptionText = getTextColorForBackground(description ?? primary);
const titleText = getTextColorForBackground(primary);
return {
primary,
secondary,
description,
descriptionText,
titleText,
};
};
const drawStandardCard = async (
context: CanvasRenderingContext2D,
card: DominionCard & { orientation: "card" }
): Promise<void> => {
const w = context.canvas.width;
// const h = context.canvas.height;
let size;
context.save();
// Draw the image
const image = await loadImage(card.image);
if (image) {
const cx = w / 2;
const cy = 704;
const windowHeight = 830;
const windowWidth = 1194;
const scale = Math.max(
windowHeight / image.height,
windowWidth / image.width
);
context.drawImage(
image,
cx - (scale * image.width) / 2,
cy - (scale * image.height) / 2,
scale * image.width,
scale * image.height
);
}
// Draw the card base
const colors = getColors(card.types); // "#ffbc55";
if (colors.secondary) {
context.drawImage(
colorImage(getImage("card-color-1"), colors.secondary),
0,
0
);
context.drawImage(
colorImage(getImage("card-color-2"), colors.primary),
0,
0
);
} else if (colors.description) {
context.drawImage(
colorImage(getImage("card-color-1"), colors.description),
0,
0
);
context.drawImage(
colorImage(getImage("card-color-2-night"), colors.primary),
0,
0
);
} else {
context.drawImage(
colorImage(getImage("card-color-1"), colors.primary),
0,
0
);
context.drawImage(getImage("card-description-focus"), 44, 1094);
}
context.drawImage(getImage("card-gray"), 0, 0);
context.drawImage(colorImage(getImage("card-brown"), "#ff9911"), 0, 0);
// Draw the name
context.fillStyle = colors.titleText;
context.font = "90pt DominionText";
const previewMeasure = await measureDominionText(
context,
parse(card.preview ?? "")
);
size = 78;
context.font = `${size}pt DominionTitle`;
while (
(await measureDominionText(context, parse(card.title))).width >
1050 - previewMeasure.width * 1.5
) {
size -= 1;
context.font = `${size}pt DominionTitle`;
}
await renderDominionText(context, parse(card.title), w / 2, 220);
// Draw the description
context.fillStyle = colors.descriptionText;
size = 60;
context.font = `${size}pt DominionText`;
while (
(
await measureDominionText(
context,
parse(card.description, { isDescription: true }),
1000
)
).height > 650
) {
size -= 1;
context.font = `${size}pt DominionText`;
}
await renderDominionText(
context,
parse(card.description, { isDescription: true }),
w / 2,
1490,
1000
);
// Draw the types
context.fillStyle = colors.titleText;
size = 65;
context.font = `${size}pt DominionTitle`;
while (
(
await measureDominionText(
context,
parse(card.types.map((t) => t.name).join(" - "))
)
).width > 800
) {
size -= 1;
context.font = `${size}pt DominionTitle`;
}
await renderDominionText(
context,
parse(card.types.map((t) => t.name).join(" - ")),
w / 2,
1930,
800
);
// Draw the cost
context.fillStyle = colors.titleText;
context.font = "90pt DominionText";
const costMeasure = await measureDominionText(context, parse(card.cost));
await renderDominionText(
context,
parse(card.cost),
130 + costMeasure.width / 2,
1940
);
// Draw the preview
context.fillStyle = colors.titleText;
if (card.preview) {
context.font = "90pt DominionText";
await renderDominionText(context, parse(card.preview), 200, 210);
await renderDominionText(context, parse(card.preview), w - 200, 210);
}
// Draw the expansion icon
// Draw the author credit
context.fillStyle = "white";
context.font = "31pt DominionText";
const authorMeasure = await measureDominionText(
context,
parse(card.author)
);
await renderDominionText(
context,
parse(card.author),
w - 150 - authorMeasure.width / 2,
2035
);
// Draw the artist credit
context.fillStyle = "white";
const artistMeasure = await measureDominionText(
context,
parse(card.artist)
);
await renderDominionText(
context,
parse(card.artist),
155 + artistMeasure.width / 2,
2035
);
// Restore the context
context.restore();
};
const drawLandscapeCard = async (
_context: CanvasRenderingContext2D,
_card: DominionCard & { orientation: "landscape" }
): Promise<void> => {
// TODO: everything
};

41
src/fonthelper.ts Normal file
View File

@ -0,0 +1,41 @@
import font from "npm:css-font";
export type FontInfo = {
style: "normal" | "italic" | "oblique";
variant: "normal" | "small-caps";
weight:
| "normal"
| "bold"
| "lighter"
| "bolder"
| "100"
| "200"
| "300"
| "400"
| "500"
| "600"
| "700"
| "800"
| "900";
stretch:
| "normal"
| "condensed"
| "semi-condensed"
| "extra-condensed"
| "ultra-condensed"
| "expanded"
| "semi-expanded"
| "extra-expanded"
| "ultra-expanded";
lineHeight: "normal" | number | string;
size: number | string;
family: string[];
};
export const parseFont = (fontString: string): FontInfo => {
return { ...font.parse(fontString) };
};
export const stringifyFont = (fontInfo: FontInfo): string => {
return font.stringify(fontInfo);
};

View File

@ -1,46 +0,0 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
@font-face {
font-family: 'myTitle';
font-display: auto;
src: local("Trajan Pro Bold"), local("TrajanPro-Bold"), local('Trajan Pro'),
url('https://fonts.cdnfonts.com/s/14928/TrajanPro-Bold.woff') format('woff'),
url('https://shemitz.net/static/dominion3/Trajan%20Pro%20Bold.ttf') format('truetype'),
url('https://dominion.games/fonts/TrajanPro-Bold.otf') format('opentype'),
local("Trajan"),
local("Optimus Princeps"),
url(https://fonts.gstatic.com/s/cinzel/v8/8vIJ7ww63mVu7gt79mT7PkRXMw.woff2) format('woff2');
}
@font-face {
font-family: 'myText';
font-display: auto;
src: local("Times New Roman"), serif;
}
@font-face {
font-family: 'mySpecials';
font-display: auto;
src: local("Minion Std Black"), local("MinionStd-Black"), local("Minion Std"), local('Minion Pro'),
url('https://fonts.cdnfonts.com/s/13260/MinionPro-Regular.woff') format('woff'),
url('https://shemitz.net/static/dominion3/MinionStd-Black.otf') format('opentype'),
local("Optimus Princeps"),
url(https://fonts.gstatic.com/s/cinzel/v8/8vIJ7ww63mVu7gt79mT7PkRXMw.woff2) format('woff2');
}
.card {
width: 2in;
border: 1px solid black;
}

View File

@ -1,19 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

0
src/isocanvas.ts Normal file
View File

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1 +0,0 @@
/// <reference types="react-scripts" />

View File

@ -1,15 +0,0 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

25
src/richtext.ts Normal file
View File

@ -0,0 +1,25 @@
// type RichnessNodeDefinition<N extends {type: string}> = {
// type: N["type"]
// measure(context: CanvasRenderingContext2D, node: N): Promise<TextMetrics>;
// render(
// context: CanvasRenderingContext2D,
// node: N,
// x: number,
// y: number
// ): Promise<void>;
// };
// type Richness<N extends {type: string}> = {[K in N["type"]]: RichnessNodeDefinition<N & {type: K}>}
// const drawRichText = <N extends {type: string}>(
// context: CanvasRenderingContext2D,
// richness: Richness<N>,
// richText: N[],
// x: number,
// y: number,
// maxWidth: number,
// ) => {
// context.save();
// const
// context.restore();
// };

78
src/sampleData.ts Normal file
View File

@ -0,0 +1,78 @@
import {
DominionCard,
TYPE_ACTION,
TYPE_DURATION,
TYPE_REACTION,
TYPE_TREASURE,
TYPE_VICTORY,
} from "./types.ts";
export const sampleCards: DominionCard[] = [
{
orientation: "card",
title: "Title",
description:
"+*\n\nReveal the top card of your deck. If it's an Action card, +1 Action. If it has ^ in its cost, +1 Card.",
types: [TYPE_ACTION, TYPE_DURATION, TYPE_REACTION],
image: "https://wiki.dominionstrategy.com/images/7/76/AdventurerArt.jpg",
expansionIcon: "",
artist: "Dall-E",
author: "John Doe",
version: "",
cost: "@8",
preview: "",
},
{
orientation: "card",
title: "Market",
description: "+1 Card\n+1 Action\n+1 Buy\n+$1",
types: [TYPE_ACTION],
image: "",
expansionIcon: "",
artist: "Leonardo DaVinci",
author: "Jane Smith",
version: "",
cost: "$4",
preview: "",
},
{
orientation: "card",
title: "Flask",
description:
"+2 Cards\n\nAt the start of your Clean-up phase, you may put a card from your hand onto your deck.",
types: [TYPE_TREASURE],
image: "",
expansionIcon: "",
artist: "",
author: "",
version: "",
cost: "$6",
preview: "",
},
{
orientation: "card",
title: "VP Card",
description: "Worth 1% per 3 cards you have that cost $4 or $5.",
types: [TYPE_VICTORY],
image: "",
expansionIcon: "",
artist: "",
author: "",
version: "",
cost: "$3",
preview: "",
},
{
orientation: "card",
title: "Nobles",
description: "Choose one: +3 Cards, or +2 Actions.\n\n\n-\n\n\n2%",
types: [TYPE_ACTION, TYPE_VICTORY],
image: "",
expansionIcon: "",
artist: "",
author: "",
version: "",
cost: "$6",
preview: "",
},
];

14
src/server/index.ts Normal file
View File

@ -0,0 +1,14 @@
import { serveDir, serveFile } from "jsr:@std/http/file-server";
Deno.serve((req: Request) => {
const pathname = new URL(req.url).pathname;
if (pathname.startsWith("/static")) {
return serveDir(req, {
fsRoot: "static",
urlRoot: "static",
});
} else {
return serveFile(req, "static/index.html");
}
});

View File

@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

171
src/types.ts Normal file
View File

@ -0,0 +1,171 @@
export type DominionText = string;
export type DominionColor = {
value: string;
priority: number; // highest priority is "primary", second highest is "secondary".
overridesAction?: boolean;
onConflictDescriptionOnly?: boolean;
};
export type DominionBasicCardType = {
typeType: "basic";
name:
| "Action"
| "Treasure"
| "Victory"
| "Curse"
| "Reaction"
| "Duration"
| "Reserve"
| "Night"
| "Attack"
| "Command";
color: null | DominionColor;
};
export type DominionBasicLandscapeType = {
typeType: "basic";
name: "Event" | "Landmark" | "Project" | "Way" | "Trait";
color: null | DominionColor;
};
export type DominionCardType = DominionBasicCardType | DominionCustomCardType;
export type DominionLandscapeType =
| DominionBasicLandscapeType
| DominionCustomLandscapeType;
export type DominionCard =
| {
orientation: "card";
title: string;
description: DominionText;
types: Array<DominionCardType>;
image: string;
artist: string;
author: string;
version: string;
cost: DominionText;
expansionIcon: string;
preview?: DominionText;
}
| {
orientation: "landscape";
title: string;
description: DominionText;
types: Array<DominionLandscapeType>;
image: string;
artist: string;
author: string;
version: string;
cost: DominionText;
};
export type DominionCustomSymbol = {
image: string;
};
export type DominionCustomCardType = {
typeType: "custom";
name: string;
color: DominionColor;
};
export type DominionCustomLandscapeType = {
typeType: "custom";
name: string;
color: DominionColor;
};
export type DominionExpansion = {
cards: Array<DominionCard>;
icon: string;
customSymbols: Array<DominionCustomSymbol>;
customCardTypes: Array<DominionCustomCardType>;
customLandscapeTypes: Array<DominionCustomLandscapeType>;
};
export const TYPE_ACTION: DominionBasicCardType = {
typeType: "basic",
name: "Action",
color: {
value: "white",
priority: 6,
},
};
export const TYPE_TREASURE: DominionBasicCardType = {
typeType: "basic",
name: "Treasure",
color: {
value: "#ffe076",
priority: 5,
},
};
export const TYPE_VICTORY: DominionBasicCardType = {
typeType: "basic",
name: "Victory",
color: {
value: "#b3e5ad",
priority: 4,
},
};
export const TYPE_CURSE: DominionBasicCardType = {
typeType: "basic",
name: "Curse",
color: {
value: "#d285ff",
priority: 4,
},
};
export const TYPE_REACTION: DominionBasicCardType = {
typeType: "basic",
name: "Reaction",
color: {
value: "#81adff",
priority: 1,
overridesAction: true,
},
};
export const TYPE_DURATION: DominionBasicCardType = {
typeType: "basic",
name: "Duration",
color: {
value: "#ffbc55",
priority: 3,
overridesAction: true,
},
};
export const TYPE_RESERVE: DominionBasicCardType = {
typeType: "basic",
name: "Reserve",
color: {
value: "#e5c28b",
priority: 2, // unknown whether this should be above or below reaction/duration?
overridesAction: true,
},
};
export const TYPE_NIGHT: DominionBasicCardType = {
typeType: "basic",
name: "Night",
color: {
value: "#485058",
priority: 6,
onConflictDescriptionOnly: true,
},
};
export const TYPE_ATTACK: DominionBasicCardType = {
typeType: "basic",
name: "Attack",
color: null,
};
export const TYPE_COMMAND: DominionBasicCardType = {
typeType: "basic",
name: "Command",
color: null,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
static/assets/CardBrown.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
static/assets/CardGray.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
static/assets/Coin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
static/assets/Debt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

BIN
static/assets/Heirloom.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

BIN
static/assets/MatIcon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 735 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
static/assets/Potion.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
static/assets/Sun.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

BIN
static/assets/Traveller.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
static/assets/VP-Token.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
static/assets/VP.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

27
static/fonts.css Normal file
View File

@ -0,0 +1,27 @@
/* @font-face {
font-family: 'DominionTitle';
font-display: block;
src: local("Trajan Pro Bold"), local("TrajanPro-Bold"), local('Trajan Pro'),
url('https://fonts.cdnfonts.com/s/14928/TrajanPro-Bold.woff') format('woff'),
url('https://shemitz.net/static/dominion3/Trajan%20Pro%20Bold.ttf') format('truetype'),
url('https://dominion.games/fonts/TrajanPro-Bold.otf') format('opentype'),
local("Trajan"),
local("Optimus Princeps"),
url(https://fonts.gstatic.com/s/cinzel/v8/8vIJ7ww63mVu7gt79mT7PkRXMw.woff2) format('woff2');
} */
@font-face {
font-family: 'DominionText';
font-display: block;
src: local("Times New Roman"), serif;
}
/* @font-face {
font-family: 'DominionSpecial';
font-display: block;
src: local("Minion Std Black"), local("MinionStd-Black"), local("Minion Std"), local('Minion Pro'),
url('https://fonts.cdnfonts.com/s/13260/MinionPro-Regular.woff') format('woff'),
url('https://shemitz.net/static/dominion3/MinionStd-Black.otf') format('opentype'),
local("Optimus Princeps"),
url(https://fonts.gstatic.com/s/cinzel/v8/8vIJ7ww63mVu7gt79mT7PkRXMw.woff2) format('woff2');
} */

13
static/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dominionator</title>
<link rel="stylesheet" href="/static/fonts.css"/>
</head>
<body>
<div id="root"></div>
<script src="/static/dist/bundle.js"></script>
</body>
</html>

28
tools/build.ts Normal file
View File

@ -0,0 +1,28 @@
import * as esbuild from "npm:esbuild";
import { denoPlugins } from "jsr:@luca/esbuild-deno-loader";
import browserslist from "npm:browserslist";
import { projectRootDir } from "../root.ts";
const browsers = browserslist([
"last 4 Chrome versions",
"last 4 Edge versions",
"last 4 Opera versions",
"last 4 Firefox versions",
"last 4 Safari versions",
]).map((browser: string) => browser.replace(" ", ""));
// esbuild target is fine-grained: https://esbuild.github.io/api/#target
const target = [...browsers, "ios18", "ios17", "ios16", "ios14"];
await esbuild.build({
plugins: [...denoPlugins()],
absWorkingDir: projectRootDir,
entryPoints: ["src/client/index.tsx"],
outfile: "static/dist/bundle.js",
bundle: true,
format: "esm",
target,
jsx: "automatic",
jsxImportSource: "react",
});
esbuild.stop();

37
tools/dev.ts Normal file
View File

@ -0,0 +1,37 @@
runConcurrentTasks().catch((error) => {
console.error("Error running tasks:", error);
Deno.exit(1);
});
async function runConcurrentTasks() {
const tasks = [
runCommand("deno task build:watch"),
runCommand("deno task serve:watch"),
];
const results = await Promise.all(tasks);
if (results.includes(false)) {
console.error("One or more tasks failed.");
Deno.exit(1);
} else {
console.log("All tasks completed successfully.");
}
}
async function runCommand(fullCommand: string) {
const [command, ...args] = fullCommand.split(" ");
const cmd = new Deno.Command(command!, {
args,
stdout: "piped",
});
const process = cmd.spawn();
const status = await process.status;
if (status.code !== 0) {
console.error(`Command failed: ${command}`);
return false;
}
return true;
}

View File

@ -1,26 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}