diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index f657c42..7822dec 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -6,7 +6,31 @@ on: - "main" jobs: + docgen: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Install + run: | + [ -f ./yarn.lock ] && yarn || npm i + + - name: Document Generation + run: npm run docs + + - name: Git Auto Commit + uses: stefanzweifel/git-auto-commit-action@v4.4.1 + with: + commit_message: 'docs: pixel-art' + file_pattern: docs/* + commit_user_name: github-actions[bot] + commit_user_email: 41898282+github-actions[bot]@users.noreply.github.com + commit_author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> + push_options: '--force' + release: + needs: docgen runs-on: ubuntu-latest steps: - name: Checkout @@ -30,29 +54,6 @@ jobs: release_name: ${{ steps.changelog.outputs.tag }} body: ${{ steps.changelog.outputs.clean_changelog }} - docgen: - needs: release - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Install - run: | - [ -f ./yarn.lock ] && yarn || npm i - - - name: Document Generation - run: npm run docs - - - name: Git Auto Commit - uses: stefanzweifel/git-auto-commit-action@v4.4.1 - with: - commit_message: 'docs: pixel-art' - file_pattern: docs/* - commit_user_name: github-actions[bot] - commit_user_email: 41898282+github-actions[bot]@users.noreply.github.com - push_options: '--force' - gh-pages: needs: release runs-on: ubuntu-latest @@ -92,5 +93,6 @@ jobs: with: target_branch: gh-pages build_dir: dist + commit_message: 'chore(release): Deploy to GitHub pages' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index fba3f37..9a4e2cb 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ +# Pixel Art + [![Auto release](https://github.com/divlook/pixel-art/actions/workflows/auto-release.yml/badge.svg)](https://github.com/divlook/pixel-art/actions/workflows/auto-release.yml) [![GitHub release](https://img.shields.io/github/release/divlook/pixel-art.svg)](https://github.com/divlook/pixel-art/releases) -# Pixel Art - ## Demo - https://divlook.github.io/pixel-art diff --git a/libs/env.amd.js b/libs/env.cjs.js similarity index 100% rename from libs/env.amd.js rename to libs/env.cjs.js diff --git a/libs/pixel-art.ts b/libs/pixel-art.ts index 7ec0c8f..88876a2 100644 --- a/libs/pixel-art.ts +++ b/libs/pixel-art.ts @@ -2,29 +2,24 @@ export interface PixelArtOptions { canvas: HTMLCanvasElement imgUrl: string /** - * @default 10 - */ - minSize?: number - /** - * @default 20 - */ - maxSize?: number - /** - * @default 10 - */ - intervalMs?: number - /** - * @default 0 + * @default 'pointillism' */ - initialDrawCount?: number + type?: ArtType /** - * @default 'square' + * level이 높을수록 point 크기가 커집니다. + * 1 ~ 5까지 입력할 수 있습니다. + * + * @default 2 */ - shape?: Shape + level?: number /** - * @default 'pointillism' + * point 투명도 + * + * - 범위 : 0 ~ 1 + * + * @default 0.2 */ - type?: ArtType + alpha?: number } export type VoidCallback = () => void @@ -36,7 +31,7 @@ export interface Coord { export type Shape = 'circle' | 'square' -export type RGBA = [number, number, number, number] +export type RGBFormat = [number, number, number] export interface Hook { type: HookType @@ -45,17 +40,14 @@ export interface Hook { export type HookType = 'initialize' | 'beforeDraw' | 'afterDraw' -export type ArtType = 'pointillism' +export type ArtType = 'pointillism' | 'mosaic' export class PixelArt { canvas!: HTMLCanvasElement imgUrl!: string - minSize!: number - maxSize!: number - intervalMs!: number - initialDrawCount!: number - shape!: Shape type!: ArtType + level!: number + alpha!: number #shadowCanvas!: HTMLCanvasElement #shadowCtx!: CanvasRenderingContext2D @@ -68,20 +60,52 @@ export class PixelArt { #requestAnimationId: number | null = null #lastAnimationTimeMs = 0 #drawCount = 0 + #unusedCoords: Coord[] = [] + #colorMap: RGBFormat[][] = [] + #intervalMs = 100 get drawCount() { return this.#drawCount } + get pointWidth() { + let pointWidth = this.canvas.width + + switch (this.level) { + case 1: + pointWidth *= 1 / 300 + break + + case 2: + pointWidth *= 1 / 120 + break + + case 4: + pointWidth *= 1 / 40 + break + + case 5: + pointWidth *= 1 / 30 + break + + default: + pointWidth *= 1 / 60 + break + } + + return Math.ceil(pointWidth) + } + + get unusedCoordsCount() { + return this.#unusedCoords.length + } + constructor(options: PixelArtOptions) { this.canvas = options.canvas this.imgUrl = options.imgUrl - this.minSize = options.minSize ?? 10 - this.maxSize = options.maxSize ?? 20 - this.intervalMs = options.intervalMs ?? 10 - this.initialDrawCount = options.initialDrawCount ?? 0 - this.shape = options.shape ?? 'square' this.type = options.type ?? 'pointillism' + this.level = Math.min(Math.max(1, options.level ?? 2), 5) + this.alpha = options.alpha ?? 0.2 this.#shadowCanvas = document.createElement('canvas') } @@ -109,9 +133,7 @@ export class PixelArt { this.#shadowCtx.drawImage(this.#img, 0, 0, width, height) this.#imageData = this.#shadowCtx.getImageData(0, 0, width, height) - for (let i = 0; i < this.initialDrawCount; i++) { - this.draw() - } + await this.createColorMap() this.execHook('initialize') @@ -128,13 +150,17 @@ export class PixelArt { }) } - afterInitialize(callback: VoidCallback) { - if (!this.#isInitialized) { - this.#que.push(callback) - return - } + afterInitialize(callback: () => T | Promise) { + return new Promise(async (resolve) => { + if (!this.#isInitialized) { + this.#que.push(async () => { + resolve(await callback()) + }) + return + } - callback() + resolve(await callback()) + }) } addHook(type: HookType, callback: VoidCallback) { @@ -150,100 +176,140 @@ export class PixelArt { } getRandomCoord() { - return new Promise((resolve) => { - this.afterInitialize(() => { - const coord: Coord = { - x: this.random(0, this.canvas.width), - y: this.random(0, this.canvas.height), - } + return this.afterInitialize(() => { + if (this.unusedCoordsCount) { + const index = this.random(0, this.unusedCoordsCount) - resolve(coord) - }) + return this.#unusedCoords.splice(index, 1)[0] + } + + const cols = Math.ceil(this.canvas.width / this.pointWidth) + const rows = Math.ceil(this.canvas.height / this.pointWidth) + + const coord: Coord = { + x: this.random(0, cols) * this.pointWidth, + y: this.random(0, rows) * this.pointWidth, + } + + return coord }) } - getColor(xCoord: number, yCoord: number) { - return new Promise((resolve) => { - this.afterInitialize(() => { - const image = this.#imageData.data - const redIndex = yCoord * (this.canvas.width * 4) + xCoord * 4 - const greenIndex = redIndex + 1 - const blueIndex = redIndex + 2 - const alphaIndex = redIndex + 3 - const rgba: RGBA = [ - image[redIndex], - image[greenIndex], - image[blueIndex], - image[alphaIndex], - ] - - resolve(rgba) - }) + getRGB(xCoord: number, yCoord: number) { + return this.afterInitialize(() => { + const image = this.#imageData.data + const redIndex = yCoord * (this.#imageData.width * 4) + xCoord * 4 + const greenIndex = redIndex + 1 + const blueIndex = redIndex + 2 + const rgb: RGBFormat = [ + image[redIndex], + image[greenIndex], + image[blueIndex], + ] + + return rgb }) } draw() { - return new Promise((resolve) => { - this.afterInitialize(async () => { - this.execHook('beforeDraw') - - const { x, y } = await this.getRandomCoord() - const rgba = await this.getColor(x, y) - const diameter = this.random(this.minSize, this.maxSize, true) - const radius = Math.round(diameter / 2) - - this.#ctx.shadowBlur = 20 - this.#ctx.shadowColor = `rgba(${rgba.join(',')})` - this.#ctx.fillStyle = `rgba(${rgba.slice(0, 3).join(',')}, 0.2)` - this.#ctx.beginPath() - if (this.shape === 'circle') { + return this.afterInitialize(async () => { + this.execHook('beforeDraw') + const random = this.random.bind(this) + + let width = this.pointWidth + + switch (this.type) { + case 'pointillism': { + width = random(width, width * 1.5, true) + break + } + case 'mosaic': { + width = this.pointWidth + break + } + } + + let { x, y } = await this.getRandomCoord() + const rgb = await this.getRGB(x, y) + const radius = Math.round(width / 2) + const shadowColor = `rgb(${rgb.join(',')})` + const fillStyle = `rgba(${rgb.join(',')}, ${this.alpha})` + + switch (this.type) { + case 'pointillism': { + + const correctionValue = width / 1.5 + + x = random(Math.abs(x - correctionValue), x + correctionValue, true) + y = random(Math.abs(y - correctionValue), y + correctionValue, true) + + this.#ctx.shadowBlur = 20 + this.#ctx.shadowColor = shadowColor + this.#ctx.fillStyle = fillStyle + this.#ctx.beginPath() this.#ctx.ellipse(x, y, radius, radius, 0, 0, Math.PI * 2) - } else { - this.#ctx.rect(x - radius, y - radius, diameter, diameter) + this.#ctx.fill() + break } - this.#ctx.fill() - this.#drawCount++ + case 'mosaic': { + this.#ctx.shadowBlur = 0 + this.#ctx.shadowColor = '' + this.#ctx.fillStyle = fillStyle + this.#ctx.beginPath() + this.#ctx.rect(x, y, width, width) + this.#ctx.fill() + break + } + } - this.execHook('afterDraw') + this.#drawCount++ - resolve() - }) + this.execHook('afterDraw') }) } startAnimation() { - return new Promise((resolve) => { - this.afterInitialize(() => { - this.#requestAnimationId = requestAnimationFrame((time) => { - if (!this.#lastAnimationTimeMs) { - this.#lastAnimationTimeMs = time - } + return this.afterInitialize(() => { + this.#requestAnimationId = requestAnimationFrame((time) => { + if (!this.#lastAnimationTimeMs) { + this.#lastAnimationTimeMs = time + } + + if (time - this.#lastAnimationTimeMs >= this.#intervalMs) { + this.#lastAnimationTimeMs = time - if (time - this.#lastAnimationTimeMs >= this.intervalMs) { - this.#lastAnimationTimeMs = time + /** + * TODO: duration에 따라 계산해야됨 + * TODO: 성능 테스트 필요 + */ + for (let i = 0; i < 100; i++) { this.draw() } + } - this.startAnimation() - }) - resolve() + switch (this.type) { + case 'mosaic': { + if (this.unusedCoordsCount === 0) { + return + } + break + } + } + + this.startAnimation() }) }) } - cancelAnimation() { - return new Promise((resolve) => { - this.afterInitialize(() => { - this.#lastAnimationTimeMs = 0 + stopAnimation() { + return this.afterInitialize(() => { + this.#lastAnimationTimeMs = 0 - if (this.#requestAnimationId === null) { - resolve() - return - } + if (this.#requestAnimationId === null) { + return + } - cancelAnimationFrame(this.#requestAnimationId) - resolve() - }) + cancelAnimationFrame(this.#requestAnimationId) }) } @@ -254,24 +320,57 @@ export class PixelArt { } clear() { - return new Promise((resolve) => { - this.afterInitialize(() => { - this.#ctx.fillStyle = `white` - this.#ctx.beginPath() - this.#ctx.rect(0, 0, this.canvas.width, this.canvas.height) - this.#ctx.fill() - resolve() - }) + return this.afterInitialize(async () => { + this.#ctx.fillStyle = `white` + this.#ctx.beginPath() + this.#ctx.rect(0, 0, this.canvas.width, this.canvas.height) + this.#ctx.fill() }) } reset() { - return new Promise((resolve) => { - this.afterInitialize(async () => { - await this.clear() - await this.init() - resolve() - }) + return this.afterInitialize(async () => { + await this.stopAnimation() + await this.clear() + await this.init() + }) + } + + /** + * @todo 성능 문제때문에 워커로 분리 필요 + */ + createColorMap() { + return this.afterInitialize(async () => { + const cols = Math.ceil(this.canvas.width / this.pointWidth) + const rows = Math.ceil(this.canvas.height / this.pointWidth) + const que: Promise[] = [] + const multiple = Math.ceil(1 / this.alpha) * 10 + + this.#unusedCoords = [] + this.#colorMap = Array(rows) + .fill(null) + .map((_, row) => { + return Array(cols) + .fill(null) + .map((_, col) => { + const x = col * this.pointWidth + const y = row * this.pointWidth + + for (let i = 0; i < multiple; i++) { + this.#unusedCoords.push({ x, y }) + } + + que.push( + this.getRGB(col, row).then((rgb) => { + this.#colorMap[row][col] = rgb + }) + ) + + return [0, 0, 0] + }) + }) + + await Promise.all(que) }) } } diff --git a/libs/utils.amd.js b/libs/utils.cjs.js similarity index 91% rename from libs/utils.amd.js rename to libs/utils.cjs.js index 0f4deb3..88f137f 100644 --- a/libs/utils.amd.js +++ b/libs/utils.cjs.js @@ -1,4 +1,4 @@ -const { ASSET_PREFIX } = require('./env.amd') +const { ASSET_PREFIX } = require('./env.cjs') const asset = (pathname = '') => { const segments = [] diff --git a/next.config.js b/next.config.js index b8ffe3f..27fe39b 100644 --- a/next.config.js +++ b/next.config.js @@ -1,4 +1,4 @@ -const { asset } = require('./libs/utils.amd') +const { asset } = require('./libs/utils.cjs') const ASSET_PREFIX = asset() diff --git a/pages/_document.tsx b/pages/_document.tsx index 5d60a67..2ccbf5f 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -2,7 +2,7 @@ import React from 'react' import Document, { Html, Head, Main, NextScript } from 'next/document' import { ServerStyleSheets } from '@material-ui/core/styles' import theme from '~/libs/theme' -import { asset } from '~/libs/utils.amd' +import { asset } from '~/libs/utils.cjs' export default class MyDocument extends Document { diff --git a/pages/index.tsx b/pages/index.tsx index d85aad7..29bff4f 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -13,8 +13,8 @@ import { Paper, TextField, } from '@material-ui/core' -import { PixelArt, PixelArtOptions, Shape } from '~/libs/pixel-art' -import { asset } from '~/libs/utils.amd' +import { ArtType, PixelArt, PixelArtOptions } from '~/libs/pixel-art' +import { asset } from '~/libs/utils.cjs' const useStyles = makeStyles((theme) => ({ root: { @@ -68,13 +68,10 @@ const HomePage: NextPage = () => { const pixelArtRef = useRef() const [isOpenBottomSheet, openBottomSheet] = useState(false) const [canvasState, setCanvasState] = useState>({ - minSize: 10, - maxSize: 20, - intervalMs: 10, - initialDrawCount: 10000, - shape: 'square', + level: 2, + type: 'pointillism', }) - const shapes: Shape[] = ['circle', 'square'] + const artTypes: ArtType[] = ['pointillism', 'mosaic'] const startAnimation = useCallback(() => { const pixelArt = pixelArtRef.current @@ -83,26 +80,36 @@ const HomePage: NextPage = () => { pixelArt.startAnimation() }, []) - const cancelAnimation = useCallback(() => { + const stopAnimation = useCallback(() => { const pixelArt = pixelArtRef.current if (!pixelArt) return - pixelArt.cancelAnimation() + pixelArt.stopAnimation() }, []) - const clearCanvas = useCallback(() => { + const resetCanvas = useCallback(() => { const pixelArt = pixelArtRef.current if (!pixelArt) return - pixelArt.clear() + pixelArt.reset() }, []) - const resetCanvas = useCallback(() => { + const restartCanvas = useCallback(async () => { const pixelArt = pixelArtRef.current if (!pixelArt) return - pixelArt.reset() - }, []) + pixelArt.afterInitialize(async () => { + if (canvasState.level !== undefined) { + pixelArt.level = canvasState.level + } + + if (canvasState.type && canvasState.type !== pixelArt.type) { + pixelArt.type = canvasState.type + await pixelArt.reset() + await pixelArt.startAnimation() + } + }) + }, [canvasState]) useEffect(() => { ;(async () => { @@ -113,11 +120,8 @@ const HomePage: NextPage = () => { pixelArtRef.current = new PixelArt({ canvas: canvasRef.current, imgUrl: asset('/img/iu.jpg'), - minSize: canvasState.minSize, - maxSize: canvasState.maxSize, - intervalMs: canvasState.intervalMs, - initialDrawCount: canvasState.initialDrawCount, - shape: canvasState.shape, + level: canvasState.level, + type: canvasState.type, }) pixelArtRef.current.addHook('initialize', () => { @@ -129,27 +133,6 @@ const HomePage: NextPage = () => { })() }, []) - useEffect(() => { - const pixelArt = pixelArtRef.current - if (!pixelArt) return - - pixelArt.afterInitialize(() => { - if (canvasState.minSize !== undefined) { - pixelArt.minSize = canvasState.minSize - } - if (canvasState.maxSize !== undefined) { - pixelArt.maxSize = canvasState.maxSize - } - if (canvasState.intervalMs !== undefined) { - pixelArt.intervalMs = canvasState.intervalMs - } - if (canvasState.initialDrawCount !== undefined) { - pixelArt.initialDrawCount = canvasState.initialDrawCount - } - if (canvasState.shape) pixelArt.shape = canvasState.shape - }) - }, [canvasState]) - return ( <>
@@ -177,35 +160,30 @@ const HomePage: NextPage = () => { { - setCanvasState((prevState) => ({ - ...prevState, - minSize: parseInt(e.target.value) || 0, - })) + SelectProps={{ + native: true, }} - /> - - - - { setCanvasState((prevState) => ({ ...prevState, - maxSize: parseInt(e.target.value) || 0, + level: parseInt(e.target.value) || 0, })) }} - /> + > + {Array(5).fill(null).map((_, index) => ( + + ))} + - + {/* TODO: duration으로 변경 */} + {/* { })) }} /> - - - - { - setCanvasState((prevState) => ({ - ...prevState, - initialDrawCount: - parseInt(e.target.value) || 0, - })) - }} - /> - + */} { onChange={(e) => { setCanvasState((prevState) => ({ ...prevState, - shape: e.target.value as Shape, + type: e.target.value as ArtType, })) }} > - {shapes.map((shape) => ( - ))} @@ -265,14 +227,13 @@ const HomePage: NextPage = () => { item xs={12} container - alignItems="center" justify="flex-end" > - - - + + +