diff --git a/src/core/layer.ts b/src/core/layer.ts index 386d0a9..ae02edd 100644 --- a/src/core/layer.ts +++ b/src/core/layer.ts @@ -63,6 +63,15 @@ export function overlayPoints(layer: Overlay): Point[] { return Object.keys(layer.cells).map(k => parseCoord(k)); } +export function overlayAny(layer: Overlay, predicate: (p: Point) => boolean): boolean { + for (const k of Object.keys(layer.cells)) { + if (predicate(parseCoord(k))) { + return true; + } + } + return false; +} + export function mkOverlayFrom(points: Point[]): Overlay { const layer: Overlay = mkOverlay(); points.forEach(p => { diff --git a/src/core/reduce.ts b/src/core/reduce.ts index daf1948..3c5fe84 100644 --- a/src/core/reduce.ts +++ b/src/core/reduce.ts @@ -12,7 +12,7 @@ import { Action, Effect, GameAction } from './action'; import { getPanicFraction } from './clock'; import { Overlay, getOverlayLayer, mkOverlay, mkOverlayFrom, overlayPoints, setOverlay } from './layer'; import { GameState, Location, SceneState, SelectionState, TileEntity, mkGameSceneState } from './state'; -import { addWorldTiles, checkValid, drawOfState, isCollision, isOccupied, tryKillTileOfState } from './state-helpers'; +import { addWorldTiles, checkValid, drawOfState, filterExpiredAnimations, isCollision, isOccupied, isTilePinned, tryKillTileOfState } from './state-helpers'; import { getTileId, get_hand_tiles, get_main_tiles, get_tiles, putTileInHand, putTileInWorld, removeAllTiles, setTileLoc } from "./tile-helpers"; import { Tool, currentTool, toolOfIndex } from './tools'; @@ -156,14 +156,14 @@ export type Intent = | { t: 'startSelection' } ; -function getIntentOfMouseDown(tool: Tool, wp: WidgetPoint, button: number, mods: Set, hoverTile: TileEntity | undefined, hoverBlock: boolean): Intent { +function getIntentOfMouseDown(tool: Tool, wp: WidgetPoint, button: number, mods: Set, hoverTile: TileEntity | undefined, hoverBlock: boolean, pinned: boolean): Intent { if (button == 2) return { t: 'panWorld' }; switch (tool) { case 'pointer': if (hoverTile) { - if (hoverTile.loc.t == 'world' && vequal(hoverTile.loc.p_in_world_int, { x: 0, y: 0 })) + if (pinned) return { t: 'panWorld' }; return { t: 'dragTile', id: hoverTile.id }; } @@ -232,7 +232,9 @@ function reduceMouseDownInWorld(state: GameState, wp: WidgetPoint, button: numbe } } const hoverBlock = getOverlayLayer(state.bonusOverlay, state.bonusLayer, p_in_world_int) == 'block'; - const intent = getIntentOfMouseDown(currentTool(state), wp, button, mods, hoverTile, hoverBlock); + let pinned = + (hoverTile && hoverTile.loc.t == 'world') ? isTilePinned(state, hoverTile.id, hoverTile.loc) : false; + const intent = getIntentOfMouseDown(currentTool(state), wp, button, mods, hoverTile, hoverBlock, pinned); return reduceIntent(state, intent, wp); } @@ -316,11 +318,16 @@ function reduceGameAction(state: GameState, action: GameAction): effectful.Resul s.mouseState.p_in_canvas = action.p; })); case 'repaint': + const now = Date.now(); + const newAnimations = filterExpiredAnimations(now, state.animations); + state = produce(state, s => { + s.animations = newAnimations; + }); if (state.panic !== undefined) { if (getPanicFraction(state.panic) > 1) { return { state: { t: 'menu' }, effects: [] }; } - return gs(produce(state, s => { s.panic!.currentTime = Date.now(); })); + return gs(produce(state, s => { s.panic!.currentTime = now; })); } else { return gs(state); diff --git a/src/core/state-helpers.ts b/src/core/state-helpers.ts index 3dc6985..7c2d2f4 100644 --- a/src/core/state-helpers.ts +++ b/src/core/state-helpers.ts @@ -2,13 +2,13 @@ import { WidgetPoint } from "../ui/widget-helpers"; import { logger } from "../util/debug"; import { produce } from "../util/produce"; import { Point } from "../util/types"; -import { vequal, vint } from "../util/vutil"; +import { vadd, vequal, vint } from "../util/vutil"; import { getAssets } from "./assets"; import { Bonus } from "./bonus"; import { getLetterSample } from "./distribution"; import { checkConnected, checkGridWords, mkGridOfMainTiles } from "./grid"; -import { Layer, Overlay, getOverlayLayer, setOverlay } from "./layer"; -import { GameState, Tile, TileEntity } from "./state"; +import { Layer, Overlay, getOverlayLayer, overlayAny, overlayPoints, setOverlay } from "./layer"; +import { Animation, GameState, Location, SelectionState, Tile, TileEntity } from "./state"; import { addHandTile, addWorldTile, ensureTileId, get_hand_tiles, get_main_tiles, get_tiles, removeTile } from "./tile-helpers"; export function addWorldTiles(state: GameState, tiles: Tile[]): GameState { @@ -65,11 +65,18 @@ function killTileOfState(state: GameState, wp: WidgetPoint): GameState { switch (wp.t) { case 'world': { const p_in_world_int = vint(wp.p_in_local); + const anim: Animation = { + t: 'explosion', + center_in_world: vadd(p_in_world_int, { x: 0.5, y: 0.5 }), + duration_ms: 500, + start_ms: Date.now(), + } const tile = get_main_tiles(state).find(tile => vequal(tile.loc.p_in_world_int, p_in_world_int)); if (tile != undefined) { return checkValid(produce(removeTile(state, tile.id), s => { s.score--; + s.animations.push(anim); })); } @@ -77,6 +84,7 @@ function killTileOfState(state: GameState, wp: WidgetPoint): GameState { return checkValid(produce(state, s => { setOverlay(s.bonusOverlay, p_in_world_int, 'empty'); s.score--; + s.animations.push(anim); })); } else { @@ -145,3 +153,17 @@ export function checkValid(state: GameState): GameState { s.connectedSet = connectedSet; }); } + + +export function isTilePinned(state: GameState, tileId: string, loc: Location & { t: 'world' }): boolean { + if (state.selected && state.selected.selectedIds.includes(tileId)) { + return overlayAny(state.selected.overlay, p => vequal(p, { x: 0, y: 0 })); + } + else { + return vequal(loc.p_in_world_int, { x: 0, y: 0 }); + } +} + +export function filterExpiredAnimations(now_ms: number, anims: Animation[]): Animation[] { + return anims.filter(anim => now_ms <= anim.start_ms + anim.duration_ms); +} diff --git a/src/core/state.ts b/src/core/state.ts index 639dabf..0eadd3f 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -71,7 +71,11 @@ export type SelectionState = { selectedIds: string[], }; +export type Animation = + | { t: 'explosion', start_ms: number, duration_ms: number, center_in_world: Point }; + export type GameState = { + animations: Animation[], toolIndex: number, tile_entities: Record, invalidWords: LocatedWord[], @@ -101,6 +105,7 @@ export function mkGameSceneState(seed?: number): SceneState { export function mkGameState(seed?: number): GameState { seed = seed ?? 12345678; return { + animations: [], toolIndex: 0, tile_entities: {}, invalidWords: [], diff --git a/src/ui/instructions.tsx b/src/ui/instructions.tsx index b216bae..3b87e47 100644 --- a/src/ui/instructions.tsx +++ b/src/ui/instructions.tsx @@ -76,6 +76,7 @@ function render(ci: CanvasInfo, props: CanvasProps) { function exampleState(): GameState { const state: GameState = { + animations: [], toolIndex: 0, invalidWords: [], connectedSet: mkGridOf([]), diff --git a/src/ui/render.ts b/src/ui/render.ts index cfefb9b..6516325 100644 --- a/src/ui/render.ts +++ b/src/ui/render.ts @@ -1,9 +1,9 @@ import { getAssets } from "../core/assets"; import { getPanicFraction } from "../core/clock"; import { LocatedWord, getGrid } from "../core/grid"; -import { getOverlay, getOverlayLayer, overlayForEach } from "../core/layer"; -import { GameState, Tile, TileEntity } from "../core/state"; -import { getTileId, get_hand_tiles, get_main_tiles, get_tiles, isSelectedForDrag } from "../core/tile-helpers"; +import { getOverlay, getOverlayLayer } from "../core/layer"; +import { Animation, GameState, TileEntity } from "../core/state"; +import { getTileId, get_hand_tiles, get_main_tiles, isSelectedForDrag } from "../core/tile-helpers"; import { fillRect, strokeRect } from "../util/dutil"; import { SE2, apply, compose, inverse, translate } from '../util/se2'; import { apply_to_rect } from "../util/se2-extra"; @@ -24,6 +24,29 @@ export function paintWithScale(ci: CanvasInfo, state: GameState) { const backgroundGray = '#eeeeee'; +function drawAnimation(d: CanvasRenderingContext2D, pan_canvas_from_world: SE2, time_ms: number, anim: Animation): void { + switch (anim.t) { + case 'explosion': { + const radius_in_world = 3 * (time_ms - anim.start_ms) / anim.duration_ms; + const radvec: Point = { x: radius_in_world, y: radius_in_world }; + const rect_in_canvas = apply_to_rect(pan_canvas_from_world, { + p: vsub(anim.center_in_world, radvec), sz: vscale(radvec, 2) + }); + d.strokeStyle = '#ff0000'; + d.lineWidth = 3; + d.beginPath(); + d.arc(rect_in_canvas.p.x + rect_in_canvas.sz.x / 2, + rect_in_canvas.p.y + rect_in_canvas.sz.y / 2, + rect_in_canvas.sz.y / 2, + 0, 360, + ); + d.stroke(); + + } break; + } + +} + export function rawPaint(ci: CanvasInfo, state: GameState) { @@ -232,10 +255,17 @@ export function rawPaint(ci: CanvasInfo, state: GameState) { } } + function drawAnimations(time_ms: number) { + state.animations.forEach(anim => { + drawAnimation(d, pan_canvas_from_world, time_ms, anim); + }); + } + drawToolbar(); drawWorld(); drawHand(); drawOtherUi(); + drawAnimations(Date.now()); } export class RenderPane {