diff --git a/routes/game/+page.svelte b/routes/game/+page.svelte index 38038ca..a9ad8ae 100644 --- a/routes/game/+page.svelte +++ b/routes/game/+page.svelte @@ -4,12 +4,13 @@ import { page } from "$app/state"; import GameLoader from "@/components/GameLoader.svelte"; import { stages } from "@/stages"; -const stageDefinition = $derived( - browser ? stages.get(page.url.searchParams.get("stage") ?? "") : undefined, +const stageNum = $derived( + browser ? (page.url.searchParams.get("stage") ?? "") : "", ); +const stageDefinition = stages.get(stageNum); - + {#snippet children(loadingState)} {loadingState}... {/snippet} diff --git a/src/ability.ts b/src/ability.ts index a51a5b3..2edea7c 100644 --- a/src/ability.ts +++ b/src/ability.ts @@ -7,11 +7,13 @@ export type Coords = { }; export type AbilityInit = { enabled?: AbilityEnableOptions; + inventoryIsInfinite?: boolean; }; export type AbilityEnableOptions = { - copy: boolean; - paste: boolean; - cut: boolean; + // 回数 or Number.POSITIVE_INFINITY + copy: number; + paste: number; + cut: number; }; type History = { at: { x: number; y: number }; @@ -21,33 +23,53 @@ type History = { before: Block | null; after: Block | null; }; + enabled: { + before: AbilityEnableOptions; + after: AbilityEnableOptions; + }; }; export class AbilityControl { history: History[] = []; historyIndex = 0; inventory: Block | null = null; - inventoryIsInfinite = false; + inventoryIsInfinite: boolean; enabled: AbilityEnableOptions; focused: Coords | undefined; constructor(cx: Context, options?: AbilityInit) { this.enabled = options?.enabled ?? { - copy: true, - paste: true, - cut: true, + copy: Number.POSITIVE_INFINITY, + paste: Number.POSITIVE_INFINITY, + cut: Number.POSITIVE_INFINITY, }; + this.inventoryIsInfinite = options?.inventoryIsInfinite ?? false; + cx.uiContext.update((prev) => ({ + ...prev, + inventory: this.inventory, + inventoryIsInfinite: this.inventoryIsInfinite, + ...this.enabled, + undo: 0, + redo: 0, + })); document.addEventListener("copy", (e) => { e.preventDefault(); - if (this.enabled.copy) this.copy(cx); + if (this.enabled.copy > 0) this.copy(cx); }); document.addEventListener("cut", (e) => { e.preventDefault(); - if (this.enabled.cut) this.cut(cx); + if (this.enabled.cut > 0) this.cut(cx); }); document.addEventListener("paste", (e) => { e.preventDefault(); - if (this.enabled.paste) this.paste(cx); + if (this.enabled.paste > 0) this.paste(cx); }); } + setInventory(cx: Context, inventory: Block | null) { + this.inventory = inventory; + cx.uiContext.update((prev) => ({ + ...prev, + inventory, + })); + } highlightCoord(playerAt: Coords, facing: Facing) { let dx: number; switch (facing) { @@ -70,7 +92,11 @@ export class AbilityControl { if (!this.focused) return; const target = cx.grid.getBlock(this.focused.x, this.focused.y); if (!target || target !== Block.movable) return; - this.inventory = target; + this.setInventory(cx, target); + cx.uiContext.update((prev) => ({ + ...prev, + copy: --this.enabled.copy, + })); } paste(cx: Context) { if (!this.focused) return; @@ -80,10 +106,14 @@ export class AbilityControl { const prevInventory = this.inventory; cx.grid.setBlock(cx, this.focused.x, this.focused.y, this.inventory); if (!this.inventoryIsInfinite) { - this.inventory = null; + this.setInventory(cx, null); } - - this.pushHistory({ + const prevEnabled = { ...this.enabled }; + cx.uiContext.update((prev) => ({ + ...prev, + paste: --this.enabled.paste, + })); + this.pushHistory(cx, { at: { ...this.focused }, from: Block.air, to: prevInventory, @@ -91,6 +121,10 @@ export class AbilityControl { before: prevInventory, after: this.inventory, }, + enabled: { + before: prevEnabled, + after: this.enabled, + }, }); } cut(cx: Context) { @@ -99,10 +133,14 @@ export class AbilityControl { // removable 以外はカットできない if (!target || target !== Block.movable) return; const prevInventory = this.inventory; - this.inventory = target; + this.setInventory(cx, target); cx.grid.setBlock(cx, this.focused.x, this.focused.y, Block.air); - - this.pushHistory({ + const prevEnabled = { ...this.enabled }; + cx.uiContext.update((prev) => ({ + ...prev, + cut: --this.enabled.cut, + })); + this.pushHistory(cx, { at: { ...this.focused }, from: target, to: Block.air, @@ -110,44 +148,67 @@ export class AbilityControl { before: prevInventory, after: target, }, + enabled: { + before: prevEnabled, + after: this.enabled, + }, }); } // History については、 `docs/history-stack.png` を参照のこと - pushHistory(h: History) { + pushHistory(cx: Context, h: History) { this.history = this.history.slice(0, this.historyIndex); this.history.push(h); this.historyIndex = this.history.length; console.log(`history: ${this.historyIndex} / ${this.history.length}`); + cx.uiContext.update((prev) => ({ + ...prev, + undo: this.historyIndex, + redo: 0, + })); } undo(cx: Context) { if (this.historyIndex <= 0) return; this.historyIndex--; // undo は、巻き戻し後の index で計算する const op = this.history[this.historyIndex]; cx.grid.setBlock(cx, op.at.x, op.at.y, op.from); - this.inventory = op.inventory.before; + this.setInventory(cx, op.inventory.before); + this.enabled = op.enabled.before; + cx.uiContext.update((prev) => ({ + ...prev, + ...this.enabled, + undo: this.historyIndex, + redo: this.history.length - this.historyIndex, + })); console.log(`history: ${this.historyIndex} / ${this.history.length}`); } redo(cx: Context) { if (this.historyIndex >= this.history.length) return; const op = this.history[this.historyIndex]; this.historyIndex++; // redo は、巻き戻し前の index - this.inventory = op.inventory.after; + this.setInventory(cx, op.inventory.after); cx.grid.setBlock(cx, op.at.x, op.at.y, op.to); + this.enabled = op.enabled.after; + cx.uiContext.update((prev) => ({ + ...prev, + ...this.enabled, + undo: this.historyIndex, + redo: this.history.length - this.historyIndex, + })); console.log(`history: ${this.historyIndex} / ${this.history.length}`); } - handleKeyDown(cx: Context, e: KeyboardEvent, onGround: boolean) { + handleKeyDown(cx: Context, e: KeyboardEvent /*, onGround: boolean*/) { if (!(e.ctrlKey || e.metaKey)) return; - if (this.enabled.paste && onGround && e.key === "v") { - this.paste(cx); - } - if (this.enabled.copy && onGround && e.key === "c") { - this.copy(cx); - } - if (this.enabled.cut && onGround && e.key === "x") { - this.cut(cx); - } + // if (this.enabled.paste > 0 && onGround && e.key === "v") { + // this.paste(cx); + // } + // if (this.enabled.copy > 0 && onGround && e.key === "c") { + // this.copy(cx); + // } + // if (this.enabled.cut > 0 && onGround && e.key === "x") { + // this.cut(cx); + // } if (e.key === "z") { this.undo(cx); e.preventDefault(); diff --git a/src/components/Ability.svelte b/src/components/Ability.svelte new file mode 100644 index 0000000..692645a --- /dev/null +++ b/src/components/Ability.svelte @@ -0,0 +1,11 @@ + + + 0} /> + {name} + + {isFinite(num) ? num : "∞"} + diff --git a/src/components/Game.svelte b/src/components/Game.svelte index 1d33bf0..2cc2732 100644 --- a/src/components/Game.svelte +++ b/src/components/Game.svelte @@ -1,19 +1,75 @@ -
-
+
+
+ Stage: + {stageNum} + + Clipboard: +
+ {#if $uiContext.inventory === Block.movable} + + + {/if} +
+ + {$uiContext.inventoryIsInfinite ? "∞" : "1"} +
+
+ Abilities: + + + + + +
+ + diff --git a/src/components/GameLoader.svelte b/src/components/GameLoader.svelte index 1c422ff..b16a34a 100644 --- a/src/components/GameLoader.svelte +++ b/src/components/GameLoader.svelte @@ -6,9 +6,10 @@ import type { Snippet } from "svelte"; type Props = { children: Snippet<[string]>; + stageNum: string; stage: StageDefinition | undefined; }; -const { children, stage }: Props = $props(); +const { children, stageNum, stage }: Props = $props(); {#if browser} @@ -16,7 +17,7 @@ const { children, stage }: Props = $props(); {@render children("Downloading")} {:then { default: Game }} {#if stage} - + {:else} Stage not found! Go Back {/if} diff --git a/src/components/Key.svelte b/src/components/Key.svelte new file mode 100644 index 0000000..5bc33fa --- /dev/null +++ b/src/components/Key.svelte @@ -0,0 +1,21 @@ + + + {isMacOS ? "⌘+" + key : "Ctrl+" + key} + + diff --git a/src/context.ts b/src/context.ts index b815403..7b00108 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,4 +1,6 @@ import type { Container } from "pixi.js"; +import type { Writable } from "svelte/store"; +import type { Block } from "./constants.ts"; import type { Grid } from "./grid.ts"; export type Context = { @@ -7,9 +9,22 @@ export type Context = { // about grid system gridX: number; // total grid in X direction, NOT width of single glid gridY: number; // total grid in Y direction + marginY: number; // windowの上端とy=0上端の距離(px) grid: Grid; blockSize: number; // about time elapsed: number; + + uiContext: Writable; +}; + +export type UIContext = { + inventory: Block | null; + inventoryIsInfinite: boolean; + copy: number; + paste: number; + cut: number; + undo: number; + redo: number; }; diff --git a/src/grid.ts b/src/grid.ts index de03b0a..cadd79c 100644 --- a/src/grid.ts +++ b/src/grid.ts @@ -17,12 +17,17 @@ type GridCell = export class Grid { private stage: Container; cells: GridCell[][]; + marginY: number; // windowの上端とy=0上端の距離(px) + oobSprites: Sprite[]; constructor( stage: Container, + height: number, cellSize: number, stageDefinition: StageDefinition, ) { this.stage = stage; + this.oobSprites = []; + this.marginY = (height - cellSize * stageDefinition.length) / 2; const cells: GridCell[][] = []; for (let y = 0; y < stageDefinition.length; y++) { const rowDefinition = stageDefinition[y].split(""); @@ -37,7 +42,7 @@ export class Grid { }; row.push(cell); } else { - const sprite = createSprite(cellSize, block, x, y); + const sprite = createSprite(cellSize, block, x, y, this.marginY); stage.addChild(sprite); const cell: GridCell = { block, @@ -49,6 +54,54 @@ export class Grid { cells.push(row); } this.cells = cells; + + this.initOOBSprites(cellSize); + } + initOOBSprites(cellSize: number) { + this.oobSprites = []; + // y < 0 にはy=0のblockをコピー + for (let y = -1; y >= -Math.ceil(this.marginY / cellSize); y--) { + for (let x = 0; x < this.cells[0].length; x++) { + const cell = this.cells[0][x]; + if (cell.block === Block.block) { + const sprite = createSprite(cellSize, cell.block, x, y, this.marginY); + this.stage.addChild(sprite); + this.oobSprites.push(sprite); + } + } + } + // y > gridY にはy=gridY-1のblockをコピー + for ( + let y = this.cells.length; + y < this.cells.length + Math.ceil(this.marginY / cellSize); + y++ + ) { + for (let x = 0; x < this.cells[this.cells.length - 1].length; x++) { + const cell = this.cells[this.cells.length - 1][x]; + if (cell.block === Block.block) { + const sprite = createSprite(cellSize, cell.block, x, y, this.marginY); + this.stage.addChild(sprite); + this.oobSprites.push(sprite); + } + } + } + } + rerender(height: number, cellSize: number) { + this.marginY = (height - cellSize * this.cells.length) / 2; + // oobSpritesをすべて削除 + for (const sprite of this.oobSprites) { + this.stage.removeChild(sprite); + } + for (let y = 0; y < this.cells.length; y++) { + const row = this.cells[y]; + for (let x = 0; x < row.length; x++) { + const cell = row[x]; + if (cell.sprite) { + updateSprite(cell.sprite, cellSize, x, y, this.marginY); + } + } + } + this.initOOBSprites(cellSize); } clone(grid: Grid) { return { @@ -71,7 +124,7 @@ export class Grid { sprite: null, }; } else { - const sprite = createSprite(cx.blockSize, block, x, y); + const sprite = createSprite(cx.blockSize, block, x, y, this.marginY); this.stage.addChild(sprite); this.cells[y][x] = { block, @@ -93,12 +146,27 @@ export function blockFromDefinition(n: string) { throw new Error("no proper block"); } } -function createSprite(blockSize: number, block: Block, x: number, y: number) { +function createSprite( + blockSize: number, + block: Block, + x: number, + y: number, + marginY: number, +) { const sprite = new Sprite(rockTexture); sprite.tint = block === Block.movable ? 0xff0000 : 0xffffff; + updateSprite(sprite, blockSize, x, y, marginY); + return sprite; +} +function updateSprite( + sprite: Sprite, + blockSize: number, + x: number, + y: number, + marginY: number, +) { sprite.width = blockSize; sprite.height = blockSize; sprite.x = x * blockSize; - sprite.y = y * blockSize; - return sprite; + sprite.y = y * blockSize + marginY; } diff --git a/src/main.ts b/src/main.ts index 7c81512..56e30db 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,16 @@ import { Application, Container } from "pixi.js"; -import type { Context } from "./context.ts"; +import type { Writable } from "svelte/store"; +import type { Context, UIContext } from "./context.ts"; import { Grid } from "./grid.ts"; import { Player } from "./player.ts"; import { bunnyTexture } from "./resources.ts"; import type { StageDefinition } from "./stages.ts"; -export async function setup(el: HTMLElement, stageDefinition: StageDefinition) { +export async function setup( + el: HTMLElement, + stageDefinition: StageDefinition, + uiContext: Writable, +) { function tick() { // highlight is re-rendered every tick const highlight = player.createHighlight(cx); @@ -33,20 +38,22 @@ export async function setup(el: HTMLElement, stageDefinition: StageDefinition) { app.screen.width / gridX, app.screen.height / gridY, ); - - const grid = new Grid(stage, blockSize, stageDefinition); + const grid = new Grid(stage, app.screen.height, blockSize, stageDefinition); const cx: Context = { stage, gridX, gridY, + marginY: grid.marginY, blockSize, grid, elapsed: 0, + uiContext, }; app.ticker.add((ticker) => { cx.elapsed += ticker.deltaTime; }); + const player = new Player(cx, bunnyTexture); app.ticker.add((ticker) => player.tick(cx, ticker)); app.stage.addChild(player.sprite); @@ -59,4 +66,17 @@ export async function setup(el: HTMLElement, stageDefinition: StageDefinition) { // Append the application canvas to the document body el.appendChild(app.canvas); + + window.addEventListener("resize", () => { + const prevCx = { ...cx }; + app.renderer.resize(window.innerWidth, window.innerHeight); + const blockSize = Math.min( + app.screen.width / gridX, + app.screen.height / gridY, + ); + cx.grid.rerender(app.screen.height, blockSize); + cx.blockSize = blockSize; + cx.marginY = grid.marginY; + player.rerender(prevCx, cx); + }); } diff --git a/src/player.ts b/src/player.ts index e761894..3dd521e 100644 --- a/src/player.ts +++ b/src/player.ts @@ -32,7 +32,7 @@ export class Player { this.sprite.anchor.set(0.5, 1); // todo: 初期座標をフィールドとともにどこかで決定 this.sprite.x = 2 * cx.blockSize; - this.sprite.y = 2 * cx.blockSize; + this.sprite.y = 2 * cx.blockSize + cx.marginY; this.sprite.width = c.playerWidth * cx.blockSize; this.sprite.height = c.playerHeight * cx.blockSize; @@ -64,7 +64,7 @@ export class Player { } getCoords(cx: Context) { const x = Math.floor(this.x / cx.blockSize); - const y = Math.round(this.y / cx.blockSize) - 1; // it was not working well so take my patch + const y = Math.round((this.y - cx.marginY) / cx.blockSize) - 1; // it was not working well so take my patch return { x, y }; } createHighlight(cx: Context) { @@ -79,10 +79,13 @@ export class Player { this.facing, ); highlight.x = highlightCoords.x * cx.blockSize; - highlight.y = highlightCoords.y * cx.blockSize; + highlight.y = highlightCoords.y * cx.blockSize + cx.marginY; return highlight; } handleInput(_cx: Context, event: KeyboardEvent, eventIsKeyDown: boolean) { + if (eventIsKeyDown) { + this.ability.handleKeyDown(_cx, event /*, this.onGround*/); + } switch (event.key) { case "Control": this.holdingKeys[Inputs.Ctrl] = eventIsKeyDown; @@ -153,12 +156,13 @@ export class Player { // next〜 は次フレームの座標、inner〜 は前フレームでかつ1px内側の座標 const nextX = (this.x + this.vx * ticker.deltaTime) / cx.blockSize; - const nextBottomY = (this.y + this.vy * ticker.deltaTime) / cx.blockSize; + const nextBottomY = + (this.y - cx.marginY + this.vy * ticker.deltaTime) / cx.blockSize; const nextTopY = nextBottomY - c.playerHeight; const nextLeftX = nextX - c.playerWidth / 2; const nextRightX = nextX + c.playerWidth / 2; - const innerBottomY = (this.y - 1) / cx.blockSize; - const innerTopY = (this.y + 1) / cx.blockSize - c.playerHeight; + const innerBottomY = (this.y - cx.marginY - 1) / cx.blockSize; + const innerTopY = (this.y - cx.marginY + 1) / cx.blockSize - c.playerHeight; const innerLeftX = (this.x + 1) / cx.blockSize - c.playerWidth / 2; const innerRightX = (this.x - 1) / cx.blockSize + c.playerWidth / 2; @@ -174,12 +178,13 @@ export class Player { if (hittingCeil && this.onGround) { this.vy = 0; } else if (hittingCeil) { - this.y = (Math.ceil(nextTopY) + c.playerHeight) * cx.blockSize; + this.y = + (Math.ceil(nextTopY) + c.playerHeight) * cx.blockSize + cx.marginY; this.vy = 0; this.jumpingBegin = null; } else if (this.onGround) { // 自分の位置は衝突したブロックの上 - this.y = Math.floor(nextBottomY) * cx.blockSize; + this.y = Math.floor(nextBottomY) * cx.blockSize + cx.marginY; this.vy = 0; } // プレイヤーの右上端または右下端がブロック または右画面端 @@ -210,7 +215,7 @@ export class Player { // Todo: 直接移動させるのではなく、ゲームオーバー処理を切り分ける if (isOutOfWorldBottom(innerTopY)) { this.x = 2 * cx.blockSize; - this.y = 3 * cx.blockSize; + this.y = 3 * cx.blockSize + cx.marginY; this.vx = 0; this.vy = 0; } @@ -220,4 +225,12 @@ export class Player { this.y += this.vy * ticker.deltaTime; this.vy += c.gravity * cx.blockSize * ticker.deltaTime; } + rerender(prevCx: Context, cx: Context) { + this.sprite.width = c.playerWidth * cx.blockSize; + this.sprite.height = c.playerHeight * cx.blockSize; + this.x = (this.x / prevCx.blockSize) * cx.blockSize; + this.y = + ((this.y - prevCx.marginY) / prevCx.blockSize) * cx.blockSize + + cx.marginY; + } }