puzzle racer WIP

puzzle-racer-road-translate
Thibault Duplessis 2021-03-04 12:41:21 +01:00
parent 38da9f5b6a
commit 420617db64
34 changed files with 747 additions and 339 deletions

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -62,6 +62,7 @@
"ui/dgt",
"ui/puz",
"ui/storm",
"ui/racer",
"ui/@build/rollupProject",
"ui/@types/lichess",
"ui/@types/cash"

View File

@ -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"

View File

@ -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;
}
}

View File

@ -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%);
}
}
}
}

View File

@ -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 {

View File

@ -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();
}

View File

@ -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;

View File

@ -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();

View File

@ -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,

View File

@ -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;

View File

@ -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`)
)
)
),
]),
]);
};

View File

@ -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';
}
}
}

View File

@ -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';

View File

@ -0,0 +1,2 @@
@import '../../../common/css/theme/dark';
@import 'racer';

View File

@ -0,0 +1,2 @@
@import '../../../common/css/theme/light';
@import 'racer';

View File

@ -0,0 +1,2 @@
@import '../../../common/css/theme/transp';
@import 'racer';

View File

@ -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"
}
}

View File

@ -0,0 +1,9 @@
import { rollupProject } from '@build/rollupProject';
export default rollupProject({
main: {
name: 'LichessRacer',
input: 'src/main.ts',
output: 'racer',
},
});

View File

@ -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<CgApi | false>(false) as Prop<CgApi | false>;
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 = <A>(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),
};
}

View File

@ -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<string | undefined>;
}

View File

@ -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;

View File

@ -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'
),
]);

View File

@ -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;
}
}
}

View File

@ -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%);
}
}
}
}
}

View File

@ -1,7 +1,3 @@
@import 'side';
@import 'combo';
@import 'clock';
.storm {
&--play {
display: grid;

View File

@ -10,7 +10,6 @@ $mq-col2: $mq-col2-uniboard;
user-select: none;
}
@import 'font';
@import 'play';
@import 'end';
@import 'high';

View File

@ -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';

View File

@ -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();

View File

@ -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[];

View File

@ -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) =>