From 420617db6470341bc76b5bc0631aed04d7a87e0e Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 4 Mar 2021 12:41:21 +0100 Subject: [PATCH] puzzle racer WIP --- app/views/racer.scala | 33 +++++- app/views/storm.scala | 38 +++---- package.json | 1 + ui/build | 2 +- ui/puz/css/_clock.scss | 48 ++++++++ ui/puz/css/_combo.scss | 160 ++++++++++++++++++++++++++ ui/{storm => puz}/css/_font.scss | 0 ui/{storm => puz}/css/_side.scss | 24 ++-- ui/puz/src/clock.ts | 20 ++++ ui/puz/src/interfaces.ts | 5 +- ui/puz/src/run.ts | 5 +- ui/{storm => puz}/src/sign.ts | 0 ui/puz/src/view/chessground.ts | 4 +- ui/puz/src/view/clock.ts | 13 +-- ui/puz/src/view/util.ts | 49 ++++++++ ui/racer/css/_racer.scss | 23 ++++ ui/racer/css/build/_racer.scss | 11 ++ ui/racer/css/build/racer.dark.scss | 2 + ui/racer/css/build/racer.light.scss | 2 + ui/racer/css/build/racer.transp.scss | 2 + ui/racer/package.json | 29 +++++ ui/racer/rollup.config.js | 9 ++ ui/racer/src/ctrl.ts | 146 ++++++++++++++++++++++++ ui/racer/src/interfaces.ts | 32 ++++++ ui/racer/src/main.ts | 35 ++++++ ui/racer/src/view/main.ts | 79 +++++++++++++ ui/storm/css/_clock.scss | 50 --------- ui/storm/css/_combo.scss | 162 --------------------------- ui/storm/css/_play.scss | 4 - ui/storm/css/_storm.scss | 1 - ui/storm/css/build/_storm.scss | 4 + ui/storm/src/ctrl.ts | 16 ++- ui/storm/src/interfaces.ts | 11 +- ui/storm/src/view/main.ts | 66 ++--------- 34 files changed, 747 insertions(+), 339 deletions(-) create mode 100644 ui/puz/css/_clock.scss create mode 100644 ui/puz/css/_combo.scss rename ui/{storm => puz}/css/_font.scss (100%) rename ui/{storm => puz}/css/_side.scss (84%) create mode 100644 ui/puz/src/clock.ts rename ui/{storm => puz}/src/sign.ts (100%) create mode 100644 ui/puz/src/view/util.ts create mode 100644 ui/racer/css/_racer.scss create mode 100644 ui/racer/css/build/_racer.scss create mode 100644 ui/racer/css/build/racer.dark.scss create mode 100644 ui/racer/css/build/racer.light.scss create mode 100644 ui/racer/css/build/racer.transp.scss create mode 100644 ui/racer/package.json create mode 100644 ui/racer/rollup.config.js create mode 100644 ui/racer/src/ctrl.ts create mode 100644 ui/racer/src/interfaces.ts create mode 100644 ui/racer/src/main.ts create mode 100644 ui/racer/src/view/main.ts delete mode 100644 ui/storm/css/_clock.scss delete mode 100644 ui/storm/css/_combo.scss diff --git a/app/views/racer.scala b/app/views/racer.scala index ef4da1d290..d04ed616de 100644 --- a/app/views/racer.scala +++ b/app/views/racer.scala @@ -7,8 +7,9 @@ import play.api.libs.json._ import lila.api.Context import lila.app.templating.Environment._ import lila.app.ui.ScalatagsTemplate._ -import lila.user.User +import lila.common.String.html.safeJsonValue import lila.racer.RacerRace +import lila.user.User object racer { @@ -30,12 +31,40 @@ object racer { def show(race: RacerRace, data: JsObject, pref: JsObject)(implicit ctx: Context) = views.html.base.layout( moreCss = frag(cssTag("racer")), + moreJs = frag( + jsModule("racer"), + embedJsUnsafeLoadThen( + s"""LichessRacer.start(${safeJsonValue( + Json.obj( + "data" -> data, + "pref" -> pref, + "i18n" -> i18nJsObject(i18nKeys) + ) + )})""" + ) + ), title = "Puzzle Racer", zoomable = true, chessground = false ) { main( - div(cls := "racer racer--app")(race.toString) + div(cls := "racer racer-app racer--play")( + div(cls := "racer__board main-board"), + div(cls := "racer__side") + ) ) } + + private val i18nKeys = { + import lila.i18n.I18nKeys.{ storm => s } + List( + s.moveToStart, + s.puzzlesSolved, + s.playAgain, + s.score, + s.moves, + s.combo, + s.newRun + ).map(_.key) + } } diff --git a/app/views/storm.scala b/app/views/storm.scala index 06637ddf8c..22d5508875 100644 --- a/app/views/storm.scala +++ b/app/views/storm.scala @@ -137,26 +137,26 @@ object storm { ) private val i18nKeys = { - import lila.i18n.I18nKeys.{ storm => t } + import lila.i18n.I18nKeys.{ storm => s } List( - t.moveToStart, - t.puzzlesSolved, - t.newDailyHighscore, - t.newWeeklyHighscore, - t.newMonthlyHighscore, - t.newAllTimeHighscore, - t.previousHighscoreWasX, - t.playAgain, - t.score, - t.moves, - t.accuracy, - t.combo, - t.time, - t.timePerMove, - t.highestSolved, - t.puzzlesPlayed, - t.newRun, - t.endRun + s.moveToStart, + s.puzzlesSolved, + s.newDailyHighscore, + s.newWeeklyHighscore, + s.newMonthlyHighscore, + s.newAllTimeHighscore, + s.previousHighscoreWasX, + s.playAgain, + s.score, + s.moves, + s.accuracy, + s.combo, + s.time, + s.timePerMove, + s.highestSolved, + s.puzzlesPlayed, + s.newRun, + s.endRun ).map(_.key) } } diff --git a/package.json b/package.json index 6a85e31235..6895d51985 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "ui/dgt", "ui/puz", "ui/storm", + "ui/racer", "ui/@build/rollupProject", "ui/@types/lichess", "ui/@types/cash" diff --git a/ui/build b/ui/build index 62fe626caf..49977bc275 100755 --- a/ui/build +++ b/ui/build @@ -15,7 +15,7 @@ mkdir -p public/compiled apps1="common" apps2="chess ceval game tree chat nvui puz" -apps3="site swiss msg chat cli challenge notify learn insight editor puzzle round analyse lobby tournament tournamentSchedule tournamentCalendar simul dasher speech palantir serviceWorker dgt storm" +apps3="site swiss msg chat cli challenge notify learn insight editor puzzle round analyse lobby tournament tournamentSchedule tournamentCalendar simul dasher speech palantir serviceWorker dgt storm racer" site_plugins="tvEmbed puzzleEmbed analyseEmbed user modUser clas coordinate captcha expandText team forum account coachShow coachForm challengePage checkout login passwordComplexity tourForm teamBattleForm gameSearch userComplete infiniteScroll flatpickr teamAdmin appeal modGames" round_plugins="nvui keyboardMove" analyse_plugins="nvui studyTopicForm" diff --git a/ui/puz/css/_clock.scss b/ui/puz/css/_clock.scss new file mode 100644 index 0000000000..6ddf109650 --- /dev/null +++ b/ui/puz/css/_clock.scss @@ -0,0 +1,48 @@ +.puz-clock { + @extend %flex-center-nowrap; + + margin-bottom: -1em; + font-family: 'storm'; + + &__time { + font-size: 6em; + transition: color 0.3s; + margin: 2vh 0; + + @include breakpoint($mq-col2) { + margin: 0; + } + + .storm--mod-bonus-slow & { + color: $c-good; + } + + .storm--mod-malus-slow & { + color: $c-bad; + } + } + + @keyframes mod-fade-out { + from { + transform: translate(0, -10px); + opacity: 1; + } + + to { + transform: translate(0, -40px); + opacity: 0.3; + } + } + + &__bonus, + &__malus { + font-size: 3.5em; + color: $c-good; + margin-left: 0.3ch; + animation: mod-fade-out 1.1s ease-out; + } + + &__malus { + color: $c-bad; + } +} diff --git a/ui/puz/css/_combo.scss b/ui/puz/css/_combo.scss new file mode 100644 index 0000000000..c4acce4cfa --- /dev/null +++ b/ui/puz/css/_combo.scss @@ -0,0 +1,160 @@ +.puz-combo { + display: flex; + flex-flow: row nowrap; + + &__counter { + display: flex; + flex-flow: column; + margin-bottom: 0.25em; + + &__value { + @extend %flex-center-nowrap; + + justify-content: center; + font-family: 'storm'; + font-size: 2.4em; + line-height: 0.9em; + width: 2ch; + } + + &__combo { + @extend %roboto; + + font-size: 0.8em; + letter-spacing: -1px; + color: $c-font-dim; + } + + transition: color 0.1s; + + .storm--mod-move & { + color: $c-brag; + } + } + + &__bars { + display: flex; + flex-flow: column; + flex: 1 1 100%; + margin-left: 1em; + } + + &__bar { + @extend %box-radius; + + $c-bar-base: $c-bg-zebra2; + $c-in-base: $c-brag; + + flex: 0 0 2.2em; + background: $c-bar-base; + border: $border; + position: relative; + + &__in, + &__in-full { + @extend %box-radius; + + position: absolute; + bottom: 0; + left: 0; + height: 100%; + } + + &__in { + background: $c-in-base; + box-shadow: 0 0 15px $c-in-base; + transition: all 0.5s ease-in-out; + + .storm--mod-bonus-slow & { + display: none; + } + + .storm--mod-malus-slow & { + transition-property: width; + background: $c-bad; + box-shadow: 0 0 10px $c-bad, 0 0 20px $c-bad; + } + } + + &__in-full { + background: $c-primary; + box-shadow: 0 0 10px $c-primary, 0 0 20px $c-primary; + width: 100%; + display: none; + opacity: 0; + + @keyframes bar-full { + from { + opacity: 1; + } + + to { + opacity: 0; + } + } + + .storm--mod-bonus-slow & { + display: block; + animation: bar-full 0.9s ease-in-out; + } + } + } + + &__levels { + @extend %flex-center; + + margin: 0.3em 0 0 -0.6em; + } + + &__level { + $c-level: $c-primary; + + transform: skewX(-45deg); + flex: 21% 0 0; + margin-right: 4%; + font-size: 0.9em; + height: 1.5em; + line-height: 1.5em; + border: $border; + background: $c-bg-zebra; + text-align: center; + color: $c-font-dimmer; + font-weight: bold; + + span { + transform: skewX(45deg); + display: block; + } + + @keyframes level-fade-in { + from { + background: white; + box-shadow: 0 0 15px white, 0 0 25px white; + } + + to { + box-shadow: 0 0 10px $c-level; + } + } + + &.active { + animation: level-fade-in 1s ease-out; + background: mix($c-level, black, 80%); + border: 1px solid $c-level; + box-shadow: 0 0 10px $c-level; + color: white; + + &:nth-child(2) { + background: $c-level; + } + + &:nth-child(3) { + background: mix($c-level, white, 60%); + } + + &:nth-child(4) { + background: mix($c-level, white, 40%); + } + } + } +} diff --git a/ui/storm/css/_font.scss b/ui/puz/css/_font.scss similarity index 100% rename from ui/storm/css/_font.scss rename to ui/puz/css/_font.scss diff --git a/ui/storm/css/_side.scss b/ui/puz/css/_side.scss similarity index 84% rename from ui/storm/css/_side.scss rename to ui/puz/css/_side.scss index 5b152bcafd..5d0a4028c6 100644 --- a/ui/storm/css/_side.scss +++ b/ui/puz/css/_side.scss @@ -1,17 +1,17 @@ -.storm { - &__side { - @extend %box-neat; +$mq-col2: $mq-col2-uniboard; - display: flex; - flex-flow: column; - justify-content: space-between; - background: $c-bg-box; - margin: 0; - padding: 2vh 2vw; +.puz-side { + @extend %box-neat; - @include breakpoint($mq-col2) { - padding: 3vh 2vw; - } + display: flex; + flex-flow: column; + justify-content: space-between; + background: $c-bg-box; + margin: 0; + padding: 2vh 2vw; + + @include breakpoint($mq-col2) { + padding: 3vh 2vw; } &__top { diff --git a/ui/puz/src/clock.ts b/ui/puz/src/clock.ts new file mode 100644 index 0000000000..b2df0ad945 --- /dev/null +++ b/ui/puz/src/clock.ts @@ -0,0 +1,20 @@ +import { getNow } from './util'; +import config from './config'; + +export class Clock { + startAt: number | undefined; + initialMillis = config.clock.initial * 1000; + + start = () => { + this.startAt = getNow(); + }; + + millis = (): number => + this.startAt ? Math.max(0, this.startAt + this.initialMillis - getNow()) : this.initialMillis; + + addSeconds = (seconds: number) => { + this.initialMillis += seconds * 1000; + }; + + flag = () => !this.millis(); +} diff --git a/ui/puz/src/interfaces.ts b/ui/puz/src/interfaces.ts index 2f1d5bcad1..07eedc0a3a 100644 --- a/ui/puz/src/interfaces.ts +++ b/ui/puz/src/interfaces.ts @@ -1,5 +1,6 @@ import { Role } from 'chessground/types'; import { VNode } from 'snabbdom/vnode'; +import { Clock } from './clock'; import { Combo } from './combo'; import CurrentPuzzle from './current'; @@ -13,7 +14,7 @@ export interface Promotion { view(): MaybeVNode; } -export interface Prefs { +export interface PuzPrefs { coords: 0 | 1 | 2; is3d: boolean; destination: boolean; @@ -37,7 +38,7 @@ export interface Run { moves: number; errors: number; current: CurrentPuzzle; - clockMs: number; + clock: Clock; history: Round[]; combo: Combo; modifier: Modifier; diff --git a/ui/puz/src/run.ts b/ui/puz/src/run.ts index 9185822e12..87bfda21ca 100644 --- a/ui/puz/src/run.ts +++ b/ui/puz/src/run.ts @@ -1,13 +1,10 @@ import { Run } from './interfaces'; import { Config as CgConfig } from 'chessground/config'; -import { getNow, uciToLastMove } from './util'; +import { uciToLastMove } from './util'; import { opposite } from 'chessops'; import { makeFen, parseFen } from 'chessops/fen'; import { chessgroundDests } from 'chessops/compat'; -export const boundedClockMillis = (run: Run) => - run.startAt ? Math.max(0, run.startAt + run.clockMs - getNow()) : run.clockMs; - export const makeCgOpts = (run: Run): CgConfig => { const cur = run.current; const pos = cur.position(); diff --git a/ui/storm/src/sign.ts b/ui/puz/src/sign.ts similarity index 100% rename from ui/storm/src/sign.ts rename to ui/puz/src/sign.ts diff --git a/ui/puz/src/view/chessground.ts b/ui/puz/src/view/chessground.ts index cb3379d871..b6eaa50d9f 100644 --- a/ui/puz/src/view/chessground.ts +++ b/ui/puz/src/view/chessground.ts @@ -1,9 +1,9 @@ import changeColorHandle from 'common/coordsColor'; import resizeHandle from 'common/resize'; import { Config as CgConfig } from 'chessground/config'; -import { Prefs, UserMove } from '../interfaces'; +import { PuzPrefs, UserMove } from '../interfaces'; -export function makeConfig(opts: CgConfig, pref: Prefs, userMove: UserMove): CgConfig { +export function makeConfig(opts: CgConfig, pref: PuzPrefs, userMove: UserMove): CgConfig { return { fen: opts.fen, orientation: opts.orientation, diff --git a/ui/puz/src/view/clock.ts b/ui/puz/src/view/clock.ts index fb927ff3d3..72dbe52e33 100644 --- a/ui/puz/src/view/clock.ts +++ b/ui/puz/src/view/clock.ts @@ -12,12 +12,12 @@ let lastText: string; export default function renderClock(run: Run, onFlag: OnFlag): VNode { const malus = run.modifier.malus; const bonus = run.modifier.bonus; - return h('div.storm__clock', [ - h('div.storm__clock__time', { + return h('div.puz-clock', [ + h('div.puz-clock__time', { hook: { insert(node) { const el = node.elm as HTMLDivElement; - el.innerText = formatMs(run.clockMs); + el.innerText = formatMs(run.clock.millis()); refreshInterval = setInterval(() => renderIn(run, onFlag, el), 100); }, destroy() { @@ -25,17 +25,16 @@ export default function renderClock(run: Run, onFlag: OnFlag): VNode { }, }, }), - !!malus && malus.at > getNow() - 900 ? h('div.storm__clock__malus', '-' + malus.seconds) : null, - !!bonus && bonus.at > getNow() - 900 ? h('div.storm__clock__bonus', '+' + bonus.seconds) : null, + !!malus && malus.at > getNow() - 900 ? h('div.puz-clock__malus', '-' + malus.seconds) : null, + !!bonus && bonus.at > getNow() - 900 ? h('div.puz-clock__bonus', '+' + bonus.seconds) : null, ]); } function renderIn(run: Run, onFlag: OnFlag, el: HTMLElement) { if (!run.startAt) return; - const clock = run.clockMs; const mods = run.modifier; const now = getNow(); - const millis = run.startAt + clock - getNow(); + const millis = run.clock.millis(); const diffs = computeModifierDiff(now, mods.bonus) - computeModifierDiff(now, mods.malus); const text = formatMs(millis - diffs); if (text != lastText) el.innerText = text; diff --git a/ui/puz/src/view/util.ts b/ui/puz/src/view/util.ts new file mode 100644 index 0000000000..771353eefc --- /dev/null +++ b/ui/puz/src/view/util.ts @@ -0,0 +1,49 @@ +import { h } from 'snabbdom'; +import { VNode } from 'snabbdom/vnode'; +import config from '../config'; +import { Run } from '../interfaces'; +import { getNow } from '../util'; + +export const playModifiers = (run: Run) => { + const now = getNow(); + const malus = run.modifier.malus; + const bonus = run.modifier.bonus; + return { + 'storm--mod-puzzle': run.current.startAt > now - 90, + 'storm--mod-move': run.modifier.moveAt > now - 90, + 'storm--mod-malus-slow': !!malus && malus.at > now - 950, + 'storm--mod-bonus-slow': !!bonus && bonus.at > now - 950, + }; +}; + +export const renderCombo = (run: Run): VNode => { + const level = run.combo.level(); + return h('div.puz-combo', [ + h('div.puz-combo__counter', [ + h('span.puz-combo__counter__value', run.combo.current), + h('span.puz-combo__counter__combo', 'COMBO'), + ]), + h('div.puz-combo__bars', [ + h('div.puz-combo__bar', [ + h('div.puz-combo__bar__in', { + attrs: { style: `width:${run.combo.percent()}%` }, + }), + h('div.puz-combo__bar__in-full'), + ]), + h( + 'div.puz-combo__levels', + [0, 1, 2, 3].map(l => + h( + 'div.puz-combo__level', + { + class: { + active: l < level, + }, + }, + h('span', `${config.combo.levels[l + 1][1]}s`) + ) + ) + ), + ]), + ]); +}; diff --git a/ui/racer/css/_racer.scss b/ui/racer/css/_racer.scss new file mode 100644 index 0000000000..24603262f2 --- /dev/null +++ b/ui/racer/css/_racer.scss @@ -0,0 +1,23 @@ +.racer { + &--play { + display: grid; + + &__side { + grid-area: side; + } + + &__board { + grid-area: board; + } + + grid-row-gap: $block-gap; + grid-column-gap: $block-gap; + grid-template-areas: 'board' 'side'; + + @include breakpoint($mq-col2) { + grid-template-columns: $col2-uniboard-width $col2-uniboard-table; + grid-template-rows: fit-content(0); + grid-template-areas: 'board side'; + } + } +} diff --git a/ui/racer/css/build/_racer.scss b/ui/racer/css/build/_racer.scss new file mode 100644 index 0000000000..dab7ecf918 --- /dev/null +++ b/ui/racer/css/build/_racer.scss @@ -0,0 +1,11 @@ +@import '../../../common/css/plugin'; +@import '../../../common/css/layout/uniboard'; +@import '../../../common/css/component/board-resize'; +@import '../../../common/css/component/bar-glider'; +@import '../../../chess/css/promotion'; +@import '../../../puz/css/font'; +@import '../../../puz/css/side'; +@import '../../../puz/css/combo'; +@import '../../../puz/css/clock'; + +@import '../racer'; diff --git a/ui/racer/css/build/racer.dark.scss b/ui/racer/css/build/racer.dark.scss new file mode 100644 index 0000000000..e141c5785e --- /dev/null +++ b/ui/racer/css/build/racer.dark.scss @@ -0,0 +1,2 @@ +@import '../../../common/css/theme/dark'; +@import 'racer'; diff --git a/ui/racer/css/build/racer.light.scss b/ui/racer/css/build/racer.light.scss new file mode 100644 index 0000000000..b3788b2b6c --- /dev/null +++ b/ui/racer/css/build/racer.light.scss @@ -0,0 +1,2 @@ +@import '../../../common/css/theme/light'; +@import 'racer'; diff --git a/ui/racer/css/build/racer.transp.scss b/ui/racer/css/build/racer.transp.scss new file mode 100644 index 0000000000..d6efc08a79 --- /dev/null +++ b/ui/racer/css/build/racer.transp.scss @@ -0,0 +1,2 @@ +@import '../../../common/css/theme/transp'; +@import 'racer'; diff --git a/ui/racer/package.json b/ui/racer/package.json new file mode 100644 index 0000000000..80b98af5f2 --- /dev/null +++ b/ui/racer/package.json @@ -0,0 +1,29 @@ +{ + "name": "racer", + "version": "2.0.0", + "private": true, + "description": "lichess.org puzzle racer", + "keywords": [ + "chess", + "lichess", + "puzzle", + "racer" + ], + "author": "Thibault Duplessis", + "license": "AGPL-3.0-or-later", + "devDependencies": { + "@build/rollupProject": "2.0.0", + "@types/lichess": "2.0.0" + }, + "dependencies": { + "chessops": "^0.8.1", + "chessground": "^7.11.0", + "snabbdom": "^0.7.4", + "common": "2.0.0", + "puz": "2.0.0" + }, + "scripts": { + "dev": "rollup --config", + "prod": "rollup --config --config-prod" + } +} diff --git a/ui/racer/rollup.config.js b/ui/racer/rollup.config.js new file mode 100644 index 0000000000..31a9243f65 --- /dev/null +++ b/ui/racer/rollup.config.js @@ -0,0 +1,9 @@ +import { rollupProject } from '@build/rollupProject'; + +export default rollupProject({ + main: { + name: 'LichessRacer', + input: 'src/main.ts', + output: 'racer', + }, +}); diff --git a/ui/racer/src/ctrl.ts b/ui/racer/src/ctrl.ts new file mode 100644 index 0000000000..1ec90f840f --- /dev/null +++ b/ui/racer/src/ctrl.ts @@ -0,0 +1,146 @@ +import config from 'puz/config'; +import makePromotion from 'puz/promotion'; +import sign from 'puz/sign'; +import { Api as CgApi } from 'chessground/api'; +import { getNow, loadSound } from 'puz/util'; +import { makeCgOpts } from 'puz/run'; +import { parseUci } from 'chessops/util'; +import { prop, Prop } from 'common'; +import { Role } from 'chessground/types'; +import { RacerOpts, RacerData, RacerVm, RacerPrefs } from './interfaces'; +import { Promotion, Run } from 'puz/interfaces'; +import { Combo } from 'puz/combo'; +import CurrentPuzzle from 'puz/current'; +import { Clock } from 'puz/clock'; + +export default class StormCtrl { + private data: RacerData; + private redraw: () => void; + pref: RacerPrefs; + run: Run; + vm: RacerVm; + trans: Trans; + promotion: Promotion; + ground = prop(false) as Prop; + + constructor(opts: RacerOpts, redraw: (data: RacerData) => void) { + this.data = opts.data; + this.pref = opts.pref; + this.redraw = () => redraw(this.data); + this.trans = lichess.trans(opts.i18n); + this.run = { + moves: 0, + errors: 0, + current: new CurrentPuzzle(0, this.data.puzzles[0]), + clock: new Clock(), + history: [], + combo: new Combo(), + modifier: { + moveAt: 0, + }, + }; + this.vm = { + signed: prop(undefined), + }; + this.promotion = makePromotion(this.withGround, () => makeCgOpts(this.run), this.redraw); + if (this.data.key) setTimeout(() => sign(this.data.key!).then(this.vm.signed), 1000 * 40); + } + + end = (): void => { + this.run.history.reverse(); + this.run.endAt = getNow(); + this.ground(false); + this.redraw(); + this.sound.end(); + this.redrawSlow(); + }; + + endNow = (): void => { + this.pushToHistory(false); + this.end(); + }; + + userMove = (orig: Key, dest: Key): void => { + if (!this.promotion.start(orig, dest, this.playUserMove)) this.playUserMove(orig, dest); + }; + + playUserMove = (orig: Key, dest: Key, promotion?: Role): void => { + if (!this.run.moves) this.run.startAt = getNow(); + this.run.moves++; + this.promotion.cancel(); + const cur = this.run.current; + const uci = `${orig}${dest}${promotion ? (promotion == 'knight' ? 'n' : promotion[0]) : ''}`; + const pos = cur.position(); + const move = parseUci(uci)!; + let captureSound = pos.board.occupied.has(move.to); + pos.play(move); + if (pos.isCheckmate() || uci == cur.expectedMove()) { + cur.moveIndex++; + this.run.combo.inc(); + this.run.modifier.moveAt = getNow(); + const bonus = this.run.combo.bonus(); + if (bonus) { + this.run.modifier.bonus = bonus; + this.run.clock.addSeconds(bonus.seconds); + this.sound.bonus(); + } + if (cur.isOver()) { + this.pushToHistory(true); + if (!this.incPuzzle()) this.end(); + } else { + cur.moveIndex++; + captureSound = captureSound || pos.board.occupied.has(parseUci(cur.line[cur.moveIndex]!)!.to); + } + this.sound.move(captureSound); + } else { + lichess.sound.play('error'); + this.pushToHistory(false); + this.run.errors++; + this.run.combo.reset(); + this.run.clock.addSeconds(-config.clock.malus); + this.run.modifier.malus = { + seconds: config.clock.malus, + at: getNow(), + }; + if (this.run.clock.flag()) this.end(); + else if (!this.incPuzzle()) this.end(); + } + this.redraw(); + this.redrawQuick(); + this.redrawSlow(); + this.withGround(g => g.set(makeCgOpts(this.run))); + lichess.pubsub.emit('ply', this.run.moves); + }; + + private redrawQuick = () => setTimeout(this.redraw, 100); + private redrawSlow = () => setTimeout(this.redraw, 1000); + + private pushToHistory = (win: boolean) => + this.run.history.push({ + puzzle: this.run.current.puzzle, + win, + millis: this.run.history.length ? getNow() - this.run.current.startAt : 0, // first one is free + }); + + private incPuzzle = (): boolean => { + const index = this.run.current.index; + if (index < this.data.puzzles.length - 1) { + this.run.current = new CurrentPuzzle(index + 1, this.data.puzzles[index + 1]); + return true; + } + return false; + }; + + countWins = (): number => this.run.history.reduce((c, r) => c + (r.win ? 1 : 0), 0); + + withGround = (f: (cg: CgApi) => A): A | false => { + const g = this.ground(); + return g && f(g); + }; + + private sound = { + move: (take: boolean) => lichess.sound.play(take ? 'capture' : 'move'), + bonus: loadSound('other/ping', 0.8, 1000), + end: loadSound('other/gewonnen', 0.6, 5000), + }; +} diff --git a/ui/racer/src/interfaces.ts b/ui/racer/src/interfaces.ts new file mode 100644 index 0000000000..62fb840fd9 --- /dev/null +++ b/ui/racer/src/interfaces.ts @@ -0,0 +1,32 @@ +import { Prop } from 'common'; +import { PuzPrefs, Puzzle } from 'puz/interfaces'; + +export interface RacerOpts { + data: RacerData; + pref: RacerPrefs; + i18n: any; +} + +export interface RacerPrefs extends PuzPrefs {} + +export interface RacerData { + race: Race; + puzzles: Puzzle[]; + owner: boolean; + key?: string; +} + +export interface Race { + id: string; + players: Player[]; + startRel?: number; +} + +export interface Player { + index: number; + user?: LightUser; +} + +export interface RacerVm { + signed: Prop; +} diff --git a/ui/racer/src/main.ts b/ui/racer/src/main.ts new file mode 100644 index 0000000000..1572965c20 --- /dev/null +++ b/ui/racer/src/main.ts @@ -0,0 +1,35 @@ +import attributes from 'snabbdom/modules/attributes'; +import klass from 'snabbdom/modules/class'; +import menuHover from 'common/menuHover'; +import RacerCtrl from './ctrl'; +import { Chessground } from 'chessground'; +import { init } from 'snabbdom'; +import { RacerOpts } from './interfaces'; +import { VNode } from 'snabbdom/vnode'; + +const patch = init([klass, attributes]); + +import view from './view/main'; + +export function start(opts: RacerOpts) { + const element = document.querySelector('.racer-app') as HTMLElement; + + let vnode: VNode; + + function redraw() { + vnode = patch(vnode, view(ctrl)); + } + + const ctrl = new RacerCtrl(opts, redraw); + + const blueprint = view(ctrl); + element.innerHTML = ''; + vnode = patch(element, blueprint); + + menuHover(); + $('script').remove(); +} + +// that's for the rest of lichess to access chessground +// without having to include it a second time +window.Chessground = Chessground; diff --git a/ui/racer/src/view/main.ts b/ui/racer/src/view/main.ts new file mode 100644 index 0000000000..3e5f338b8a --- /dev/null +++ b/ui/racer/src/view/main.ts @@ -0,0 +1,79 @@ +import { Chessground } from 'chessground'; +import { makeConfig as makeCgConfig } from 'puz/view/chessground'; +import renderClock from 'puz/view/clock'; +import RacerCtrl from '../ctrl'; +import { onInsert } from 'puz/util'; +import { playModifiers, renderCombo } from 'puz/view/util'; +import { h } from 'snabbdom'; +import { VNode } from 'snabbdom/vnode'; +import { makeCgOpts } from 'puz/run'; + +export default function (ctrl: RacerCtrl): VNode { + return h( + 'div.racer.racer-app.racer--play', + { + class: playModifiers(ctrl.run), + }, + renderPlay(ctrl) + ); +} + +const chessground = (ctrl: RacerCtrl): VNode => + h('div.cg-wrap', { + hook: { + insert: vnode => + ctrl.ground( + Chessground(vnode.elm as HTMLElement, makeCgConfig(makeCgOpts(ctrl.run), ctrl.pref, ctrl.userMove)) + ), + destroy: _ => ctrl.withGround(g => g.destroy()), + }, + }); + +const renderPlay = (ctrl: RacerCtrl): VNode[] => [ + h('div.storm__board.main-board', [chessground(ctrl), ctrl.promotion.view()]), + h('div.storm__side', [ + ctrl.run.startAt ? renderSolved(ctrl) : renderStart(ctrl), + renderClock(ctrl.run, ctrl.endNow), + h('div.storm__table', [renderControls(ctrl), renderCombo(ctrl.run)]), + ]), +]; + +const renderControls = (ctrl: RacerCtrl): VNode => + h('div.storm__control', [ + h('a.storm__control__reload.button.button-empty', { + attrs: { + href: '/storm', + 'data-icon': 'B', + title: ctrl.trans('newRun'), + }, + }), + h('a.storm__control__end.button.button-empty', { + attrs: { + 'data-icon': 'b', + title: ctrl.trans('endRun'), + }, + hook: onInsert(el => el.addEventListener('click', ctrl.endNow)), + }), + ]); + +const renderSolved = (ctrl: RacerCtrl): VNode => + h('div.storm__top.storm__solved', [h('div.storm__solved__text', ctrl.countWins())]); + +const renderStart = (ctrl: RacerCtrl) => + h( + 'div.storm__top.storm__start', + h('div.storm__start__text', [h('strong', 'Puzzle Storm'), h('span', ctrl.trans('moveToStart'))]) + ); + +const renderReload = (msg: string) => + h('div.storm.storm--reload.box.box-pad', [ + h('i', { attrs: { 'data-icon': '~' } }), + h('p', msg), + h( + 'a.storm--dup__reload.button', + { + attrs: { href: '/storm' }, + }, + 'Click to reload' + ), + ]); diff --git a/ui/storm/css/_clock.scss b/ui/storm/css/_clock.scss deleted file mode 100644 index 0f298e887e..0000000000 --- a/ui/storm/css/_clock.scss +++ /dev/null @@ -1,50 +0,0 @@ -.storm { - &__clock { - @extend %flex-center-nowrap; - - margin-bottom: -1em; - font-family: 'storm'; - - &__time { - font-size: 6em; - transition: color 0.3s; - margin: 2vh 0; - - @include breakpoint($mq-col2) { - margin: 0; - } - - .storm--mod-bonus-slow & { - color: $c-good; - } - - .storm--mod-malus-slow & { - color: $c-bad; - } - } - - @keyframes mod-fade-out { - from { - transform: translate(0, -10px); - opacity: 1; - } - - to { - transform: translate(0, -40px); - opacity: 0.3; - } - } - - &__bonus, - &__malus { - font-size: 3.5em; - color: $c-good; - margin-left: 0.3ch; - animation: mod-fade-out 1.1s ease-out; - } - - &__malus { - color: $c-bad; - } - } -} diff --git a/ui/storm/css/_combo.scss b/ui/storm/css/_combo.scss deleted file mode 100644 index e28332d932..0000000000 --- a/ui/storm/css/_combo.scss +++ /dev/null @@ -1,162 +0,0 @@ -.storm { - &__combo { - display: flex; - flex-flow: row nowrap; - - &__counter { - display: flex; - flex-flow: column; - margin-bottom: 0.25em; - - &__value { - @extend %flex-center-nowrap; - - justify-content: center; - font-family: 'storm'; - font-size: 2.4em; - line-height: 0.9em; - width: 2ch; - } - - &__combo { - @extend %roboto; - - font-size: 0.8em; - letter-spacing: -1px; - color: $c-font-dim; - } - - transition: color 0.1s; - - .storm--mod-move & { - color: $c-brag; - } - } - - &__bars { - display: flex; - flex-flow: column; - flex: 1 1 100%; - margin-left: 1em; - } - - &__bar { - @extend %box-radius; - - $c-bar-base: $c-bg-zebra2; - $c-in-base: $c-brag; - - flex: 0 0 2.2em; - background: $c-bar-base; - border: $border; - position: relative; - - &__in, - &__in-full { - @extend %box-radius; - - position: absolute; - bottom: 0; - left: 0; - height: 100%; - } - - &__in { - background: $c-in-base; - box-shadow: 0 0 15px $c-in-base; - transition: all 0.5s ease-in-out; - - .storm--mod-bonus-slow & { - display: none; - } - - .storm--mod-malus-slow & { - transition-property: width; - background: $c-bad; - box-shadow: 0 0 10px $c-bad, 0 0 20px $c-bad; - } - } - - &__in-full { - background: $c-primary; - box-shadow: 0 0 10px $c-primary, 0 0 20px $c-primary; - width: 100%; - display: none; - opacity: 0; - - @keyframes bar-full { - from { - opacity: 1; - } - - to { - opacity: 0; - } - } - - .storm--mod-bonus-slow & { - display: block; - animation: bar-full 0.9s ease-in-out; - } - } - } - - &__levels { - @extend %flex-center; - - margin: 0.3em 0 0 -0.6em; - } - - &__level { - $c-level: $c-primary; - - transform: skewX(-45deg); - flex: 21% 0 0; - margin-right: 4%; - font-size: 0.9em; - height: 1.5em; - line-height: 1.5em; - border: $border; - background: $c-bg-zebra; - text-align: center; - color: $c-font-dimmer; - font-weight: bold; - - span { - transform: skewX(45deg); - display: block; - } - - @keyframes level-fade-in { - from { - background: white; - box-shadow: 0 0 15px white, 0 0 25px white; - } - - to { - box-shadow: 0 0 10px $c-level; - } - } - - &.active { - animation: level-fade-in 1s ease-out; - background: mix($c-level, black, 80%); - border: 1px solid $c-level; - box-shadow: 0 0 10px $c-level; - color: white; - - &:nth-child(2) { - background: $c-level; - } - - &:nth-child(3) { - background: mix($c-level, white, 60%); - } - - &:nth-child(4) { - background: mix($c-level, white, 40%); - } - } - } - } -} diff --git a/ui/storm/css/_play.scss b/ui/storm/css/_play.scss index bc3a8559d9..1fc52ac5c4 100644 --- a/ui/storm/css/_play.scss +++ b/ui/storm/css/_play.scss @@ -1,7 +1,3 @@ -@import 'side'; -@import 'combo'; -@import 'clock'; - .storm { &--play { display: grid; diff --git a/ui/storm/css/_storm.scss b/ui/storm/css/_storm.scss index fe42e3045d..6c4986ed8e 100644 --- a/ui/storm/css/_storm.scss +++ b/ui/storm/css/_storm.scss @@ -10,7 +10,6 @@ $mq-col2: $mq-col2-uniboard; user-select: none; } -@import 'font'; @import 'play'; @import 'end'; @import 'high'; diff --git a/ui/storm/css/build/_storm.scss b/ui/storm/css/build/_storm.scss index db0903d9a8..7d12e9d05c 100644 --- a/ui/storm/css/build/_storm.scss +++ b/ui/storm/css/build/_storm.scss @@ -3,5 +3,9 @@ @import '../../../common/css/component/board-resize'; @import '../../../common/css/component/bar-glider'; @import '../../../chess/css/promotion'; +@import '../../../puz/css/font'; +@import '../../../puz/css/side'; +@import '../../../puz/css/combo'; +@import '../../../puz/css/clock'; @import '../storm'; diff --git a/ui/storm/src/ctrl.ts b/ui/storm/src/ctrl.ts index 400ea66c96..335330463a 100644 --- a/ui/storm/src/ctrl.ts +++ b/ui/storm/src/ctrl.ts @@ -1,10 +1,10 @@ import * as xhr from './xhr'; import config from 'puz/config'; import makePromotion from 'puz/promotion'; -import sign from './sign'; +import sign from 'puz/sign'; import { Api as CgApi } from 'chessground/api'; import { getNow, loadSound } from 'puz/util'; -import { boundedClockMillis, makeCgOpts } from 'puz/run'; +import { makeCgOpts } from 'puz/run'; import { parseUci } from 'chessops/util'; import { prop, Prop } from 'common'; import { Role } from 'chessground/types'; @@ -12,6 +12,7 @@ import { StormOpts, StormData, StormVm, StormRecap, StormPrefs } from './interfa import { Promotion, Run } from 'puz/interfaces'; import { Combo } from 'puz/combo'; import CurrentPuzzle from 'puz/current'; +import { Clock } from 'puz/clock'; export default class StormCtrl { private data: StormData; @@ -32,7 +33,7 @@ export default class StormCtrl { moves: 0, errors: 0, current: new CurrentPuzzle(0, this.data.puzzles[0]), - clockMs: config.clock.initial * 1000, + clock: new Clock(), history: [], combo: new Combo(), modifier: { @@ -56,9 +57,6 @@ export default class StormCtrl { }, config.timeToStart + 1000); } - clockMillis = (): number | undefined => - this.run.startAt && Math.max(0, this.run.startAt + this.run.clockMs - getNow()); - end = (): void => { this.run.history.reverse(); this.run.endAt = getNow(); @@ -98,7 +96,7 @@ export default class StormCtrl { const bonus = this.run.combo.bonus(); if (bonus) { this.run.modifier.bonus = bonus; - this.run.clockMs += bonus.seconds * 1000; + this.run.clock.addSeconds(bonus.seconds); this.sound.bonus(); } if (cur.isOver()) { @@ -114,12 +112,12 @@ export default class StormCtrl { this.pushToHistory(false); this.run.errors++; this.run.combo.reset(); - this.run.clockMs -= config.clock.malus * 1000; + this.run.clock.addSeconds(-config.clock.malus); this.run.modifier.malus = { seconds: config.clock.malus, at: getNow(), }; - if (!boundedClockMillis(this.run)) this.end(); + if (this.run.clock.flag()) this.end(); else if (!this.incPuzzle()) this.end(); } this.redraw(); diff --git a/ui/storm/src/interfaces.ts b/ui/storm/src/interfaces.ts index fbbf533233..0fd7f0aaca 100644 --- a/ui/storm/src/interfaces.ts +++ b/ui/storm/src/interfaces.ts @@ -1,5 +1,5 @@ import { Prop } from 'common'; -import { Puzzle } from 'puz/interfaces'; +import { PuzPrefs, Puzzle } from 'puz/interfaces'; export interface StormOpts { data: StormData; @@ -7,14 +7,7 @@ export interface StormOpts { i18n: any; } -export interface StormPrefs { - coords: 0 | 1 | 2; - is3d: boolean; - destination: boolean; - rookCastle: boolean; - moveEvent: number; - highlight: boolean; -} +export interface StormPrefs extends PuzPrefs {} export interface StormData { puzzles: Puzzle[]; diff --git a/ui/storm/src/view/main.ts b/ui/storm/src/view/main.ts index a641bffabd..1770a8053f 100644 --- a/ui/storm/src/view/main.ts +++ b/ui/storm/src/view/main.ts @@ -1,10 +1,10 @@ import { Chessground } from 'chessground'; import { makeConfig as makeCgConfig } from 'puz/view/chessground'; -import config from 'puz/config'; import renderClock from 'puz/view/clock'; import renderEnd from './end'; import StormCtrl from '../ctrl'; -import { getNow, onInsert } from 'puz/util'; +import { onInsert } from 'puz/util'; +import { playModifiers, renderCombo } from 'puz/view/util'; import { h } from 'snabbdom'; import { VNode } from 'snabbdom/vnode'; import { makeCgOpts } from 'puz/run'; @@ -16,25 +16,13 @@ export default function (ctrl: StormCtrl): VNode { return h( 'div.storm.storm-app.storm--play', { - class: playModifiers(ctrl), + class: playModifiers(ctrl.run), }, renderPlay(ctrl) ); return h('main.storm.storm--end', renderEnd(ctrl)); } -const playModifiers = (ctrl: StormCtrl) => { - const now = getNow(); - const malus = ctrl.run.modifier.malus; - const bonus = ctrl.run.modifier.bonus; - return { - 'storm--mod-puzzle': ctrl.run.current.startAt > now - 90, - 'storm--mod-move': ctrl.run.modifier.moveAt > now - 90, - 'storm--mod-malus-slow': !!malus && malus.at > now - 950, - 'storm--mod-bonus-slow': !!bonus && bonus.at > now - 950, - }; -}; - const chessground = (ctrl: StormCtrl): VNode => h('div.cg-wrap', { hook: { @@ -48,23 +36,23 @@ const chessground = (ctrl: StormCtrl): VNode => const renderPlay = (ctrl: StormCtrl): VNode[] => [ h('div.storm__board.main-board', [chessground(ctrl), ctrl.promotion.view()]), - h('div.storm__side', [ + h('div.storm__side.puz-side', [ ctrl.run.startAt ? renderSolved(ctrl) : renderStart(ctrl), renderClock(ctrl.run, ctrl.endNow), - h('div.storm__table', [renderControls(ctrl), renderCombo(ctrl)]), + h('div.puz-side__table', [renderControls(ctrl), renderCombo(ctrl.run)]), ]), ]; const renderControls = (ctrl: StormCtrl): VNode => - h('div.storm__control', [ - h('a.storm__control__reload.button.button-empty', { + h('div.puz-side__control', [ + h('a.puz-side__control__reload.button.button-empty', { attrs: { href: '/storm', 'data-icon': 'B', title: ctrl.trans('newRun'), }, }), - h('a.storm__control__end.button.button-empty', { + h('a.puz-side__control__end.button.button-empty', { attrs: { 'data-icon': 'b', title: ctrl.trans('endRun'), @@ -73,45 +61,13 @@ const renderControls = (ctrl: StormCtrl): VNode => }), ]); -const renderCombo = (ctrl: StormCtrl): VNode => { - const level = ctrl.run.combo.level(); - return h('div.storm__combo', [ - h('div.storm__combo__counter', [ - h('span.storm__combo__counter__value', ctrl.run.combo), - h('span.storm__combo__counter__combo', 'COMBO'), - ]), - h('div.storm__combo__bars', [ - h('div.storm__combo__bar', [ - h('div.storm__combo__bar__in', { - attrs: { style: `width:${ctrl.run.combo.percent()}%` }, - }), - h('div.storm__combo__bar__in-full'), - ]), - h( - 'div.storm__combo__levels', - [0, 1, 2, 3].map(l => - h( - 'div.storm__combo__level', - { - class: { - active: l < level, - }, - }, - h('span', `${config.combo.levels[l + 1][1]}s`) - ) - ) - ), - ]), - ]); -}; - const renderSolved = (ctrl: StormCtrl): VNode => - h('div.storm__top.storm__solved', [h('div.storm__solved__text', ctrl.countWins())]); + h('div.puz-side__top.puz-side__solved', [h('div.puz-side__solved__text', ctrl.countWins())]); const renderStart = (ctrl: StormCtrl) => h( - 'div.storm__top.storm__start', - h('div.storm__start__text', [h('strong', 'Puzzle Storm'), h('span', ctrl.trans('moveToStart'))]) + 'div.puz-side__top.puz-side__start', + h('div.puz-side__start__text', [h('strong', 'Puzzle Storm'), h('span', ctrl.trans('moveToStart'))]) ); const renderReload = (msg: string) =>