diff --git a/README.md b/README.md index bd780db..58e8001 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,15 @@ turn it is after each change, and the result and race-to-3 line name the players The tests pin that the turn follows the shooter, that a foul passes it on, and that the AI never shoots for a human (mutation-checked). +## Lobby and the tutorial + +The lobby is grouped instead of a row of buttons: a primary row (**Find a game**, **Practice vs AI**, **Two players, one device**), the game settings (game, AI level, table, **House rules**), then +**Sharpen your game** (*How to play*, *Daily shot*, *Drills and trick shots*, *Challenges*, *Puzzle maker*) and **Solo runs** (*Career*, *Rogue Pool*). + +A player who has never opened the tutorial sees a *New here?* banner once. **How to play** is five short steps, each an ordinary drill dressed with the words for one control: aim and shoot, power, cutting a ball, +spin, and bank shots. Finishing (or *Not now*) is remembered in the browser and the banner does not return; *How to play* stays available. The steps are the drills' own, so progress is too, and Show me works on each. +`src/tutorial.js` is the steps as data; the tests solve every step, check the lobby keeps one id per control and that each one still has a handler. + ## Trick shots **Drills** now also lists twenty curated **trick shots**: small tables that one shot clears completely (eight with two balls, eight with three, four with four), easiest first. The win rule is `clear`: diff --git a/public/feature.html b/public/feature.html index 3376c45..49c181c 100644 --- a/public/feature.html +++ b/public/feature.html @@ -138,7 +138,7 @@

01The field

-

02Shipped (21)

+

02Shipped (22)

Everything below is live. Each card says what it does and, where it matters, what it does not do yet.

@@ -294,6 +294,13 @@

02Shipped Shipped

+
+
โœ“
+

A tidier lobby and a first-run tutorial

+

Shipped. The lobby is grouped: a primary row (Find a game, Practice vs AI, Two players on one device), the game settings (game, AI, table, house rules), then Sharpen your game (How to play, Daily shot, Drills and trick shots, Challenges, Puzzle maker) and Solo runs (Career, Rogue Pool). A new player is offered a five-step tutorial once: aim, power, cutting, spin and banks, each an ordinary drill with a plain explanation and a Show me.

+ Shipped +
+
โœ“

Curated trick shots

diff --git a/src/main.js b/src/main.js index f0b1133..107ef7b 100644 --- a/src/main.js +++ b/src/main.js @@ -17,6 +17,7 @@ import { pack,unpack } from './replay.js' import { normalizeHouse,isDefault as houseIsDefault,describe as describeHouse,RACES,BALL_IN_HAND,BREAKERS,STRAIGHT_TARGETS,words as houseWords } from './house.js' import { PRESETS as OBSTACLE_PRESETS,isPreset as isObstaclePreset } from './obstacles.js' import { TRICK_DRILLS,trickById,isTrick,nextTrick,tricksDone } from './tricks.js' +import { DONE_KEY as TUTORIAL_KEY,hasNext as tutorialHasNext,shouldOffer as tutorialOffer,stepDrill } from './tutorial.js' import { createPuzzleEditor } from './puzzle-editor.js' import { decode as decodePuzzle,puzzleDrill } from './puzzle.js' import { DRILLS,DRILL_TABLE,emptyProgress,recordDrill,doneCount,nextDrill } from './drills.js' @@ -56,7 +57,7 @@ setTableSize(tablePrefs.size) document.querySelector('#app').innerHTML=`
Roadmap
-

THE TABLE IS OPEN

Rack up.
Play anyone.

Instant rooms, live chat, and calls. No account required.

Open tables

League leaders

+

THE TABLE IS OPEN

Rack up.
Play anyone.

Instant rooms, live chat, and calls. No account required.

Sharpen your game

Solo runs

Open tables

League leaders

Choose your name

This device remembers you. You can change it anytime.

Name this table

Players will see this name in the open-table list.

Your league record

Trophies

Practice drills

` $('.view-buttons').insertAdjacentHTML('beforeend','') @@ -120,7 +121,7 @@ function bind(){ $('#replay-shot').onclick=()=>watch(1) $('#replay-slow').onclick=()=>watch(.25) $('#share-replay').onclick=shareReplay - $('#drills').onclick=openDrills;$('#puzzles').onclick=openPuzzleMaker + $('#drills').onclick=openDrills;$('#tutorial').onclick=()=>startTutorial(0);$('#learn-go').onclick=()=>startTutorial(0);$('#learn-skip').onclick=()=>{localStorage.setItem(TUTORIAL_KEY,'skipped');paintLearn()};paintLearn();$('#puzzles').onclick=openPuzzleMaker $('#daily').onclick=()=>startDrill(`daily-${dayNumber()}`) $('#drill-share').onclick=shareDaily paintDaily() @@ -134,7 +135,7 @@ function bind(){ $('#open-physics').onclick=()=>{$('#table-dialog').close();openPhysics()} $('#drill-hint').onclick=()=>{if(!state.game?.applyHint())toast('Wait for the balls to stop')} $('#drill-retry').onclick=()=>state.game?.retryDrill() - $('#drill-next').onclick=()=>{const n=isTrick(state.drillId)?nextTrick(state.drillId):nextDrill(state.drillId);if(n)startDrill(n.id);else{toast(isTrick(state.drillId)?'That was the last trick shot':'That was the last drill');leaveRoom().then(openDrills)}} + $('#drill-next').onclick=()=>{if(state.tutorial!=null){if(tutorialHasNext(state.tutorial))startTutorial(state.tutorial+1);else finishTutorial();return}const n=isTrick(state.drillId)?nextTrick(state.drillId):nextDrill(state.drillId);if(n)startDrill(n.id);else{toast(isTrick(state.drillId)?'That was the last trick shot':'That was the last drill');leaveRoom().then(openDrills)}} $('#drill-list').onclick=e=>{const b=e.target.closest('[data-drill]');if(b)startDrill(b.dataset.drill)} $('#replay-home').onclick=()=>leaveRoom() $('#focus-table').onclick=()=>{const focused=$('#game').classList.toggle('focus');$('#focus-table').textContent=focused?'Show chat':'Focus table';view.renderer?.resize()} @@ -303,20 +304,24 @@ async function openPuzzle(link){ await startDrill(puzzleDrill(r.puzzle,r.hint)) return true } +// The first-run tutorial: five drills, each with the words for the control it teaches. +async function startTutorial(i=0){const d=stepDrill(DRILLS,i);if(d)await startDrill(d)} +function finishTutorial(){localStorage.setItem(TUTORIAL_KEY,'done');toast('That is the basics. Try Practice vs AI next.');paintLearn();leaveRoom()} +function paintLearn(){$('#learn').hidden=!tutorialOffer(localStorage.getItem(TUTORIAL_KEY))} async function startDrill(id){ const d=typeof id==='object'?id:isDaily(id)?dailyDrill(dailyNumberOf(id)):isTrick(id)?trickById(id):DRILLS.find(x=>x.id===id);if(!d)return if($('#drills-dialog').open)$('#drills-dialog').close() state.game?.destroy();state.game=null // the shots are proven on one table: play them there, and put the player's own back afterwards if(tablePrefs.size!==DRILL_TABLE){await viewManager.previewTable(DRILL_TABLE);state.previewedTable=true;toast(`Drills use the ${DRILL_TABLE} ft table`)} - state.mode='drill';state.room=null;state.opponent=null;state.drillId=d.id + state.mode='drill';state.room=null;state.opponent=null;state.drillId=d.id;state.tutorial=d.tutorial??null;$('#drill-next').textContent=d.tutorial!=null?'Next step โ†’':'Next drill โ†’' showGame() $('#game').classList.add('focus','solo');$('.call-actions').hidden=true;$('#room-sidebar').hidden=true history.replaceState({},'',location.pathname) $('#room-label').textContent=`Drill ยท ${d.name}`;$('#match-score').textContent='' $('#versus').innerHTML=`${esc(d.name)} ${'โ—'.repeat(d.level)}${'โ—‹'.repeat(3-d.level)}${esc(d.goal)}` $('#drill-tip').textContent=d.tip - state.game=new PoolGame({drill:d,drillLabel:d.trick?`TRICK ${d.index+1} OF ${TRICK_DRILLS.length}`:d.puzzle?'PUZZLE':d.daily?`DAILY #${d.daily}`:`${DRILLS.indexOf(d)+1} OF ${DRILLS.length}`,mode:'8ball',renderer:await ensureRenderer(),surface:$('.canvas-wrap'),status:$('#game-status'),groupStatus:$('#groups'),callout:$('#callout'),power:$('#power'),powerOut:$('.shot-controls output'),shoot:$('#shoot'),jumpBtn:$('#jump'),spinPad:$('#spin'),moveCue:$('#move-cue'),changePocket:$('#change-pocket'),sfx,onReplay:paintReplayBar,onDrill:onDrillResult,aimSensitivity:tablePrefs.aimSensitivity,prefs:tablePrefs,host:true,practice:false,spectator:false,send:()=>{}}) + state.game=new PoolGame({drill:d,drillLabel:d.label||(d.trick?`TRICK ${d.index+1} OF ${TRICK_DRILLS.length}`:d.puzzle?'PUZZLE':d.daily?`DAILY #${d.daily}`:`${DRILLS.indexOf(d)+1} OF ${DRILLS.length}`),mode:'8ball',renderer:await ensureRenderer(),surface:$('.canvas-wrap'),status:$('#game-status'),groupStatus:$('#groups'),callout:$('#callout'),power:$('#power'),powerOut:$('.shot-controls output'),shoot:$('#shoot'),jumpBtn:$('#jump'),spinPad:$('#spin'),moveCue:$('#move-cue'),changePocket:$('#change-pocket'),sfx,onReplay:paintReplayBar,onDrill:onDrillResult,aimSensitivity:tablePrefs.aimSensitivity,prefs:tablePrefs,host:true,practice:false,spectator:false,send:()=>{}}) state.game.setReady(true) $('#drill-bar').hidden=false;$('#drill-next').hidden=true;$('#drill-share').hidden=true } @@ -324,7 +329,7 @@ function onDrillResult(e){ if(!e){$('#drill-next').hidden=true;$('#drill-share').hidden=true;return} if(e.drill.puzzle){if(e.ok){toast('Puzzle solved!');sfx.result(true)}return} localStorage.setItem('pool-masters:drills',JSON.stringify(recordDrill(drillProgress(),e.drill.id,{ok:e.ok,attempts:e.attempts,hinted:e.hinted}))) - if(e.ok){toast(e.drill.trick?'Table cleared!':'Drill complete!');sfx.result(true);$('#drill-next').hidden=!(e.drill.trick?nextTrick(e.drill.id):nextDrill(e.drill.id));$('#drill-share').hidden=!isDaily(e.drill.id);paintDaily();setTimeout(checkTrophies,1800)} + if(e.ok){toast(e.drill.trick?'Table cleared!':'Drill complete!');sfx.result(true);$('#drill-next').hidden=e.drill.tutorial!=null?false:!(e.drill.trick?nextTrick(e.drill.id):nextDrill(e.drill.id));$('#drill-share').hidden=!isDaily(e.drill.id);paintDaily();setTimeout(checkTrophies,1800)} } // The bar shows once a shot has been recorded, and goes again when the next begins. diff --git a/src/style.css b/src/style.css index 6384da9..eaccae4 100644 --- a/src/style.css +++ b/src/style.css @@ -159,3 +159,16 @@ dialog label:has(>input[type=checkbox]){display:flex;align-items:center;gap:10px #puzzle-status{min-height:1.3em;color:#bfe6cf} #puzzle-dialog .puzzle-tools{flex-wrap:wrap;justify-content:flex-start} .drill-head{margin:14px 0 4px;font-size:.9rem}.drill-head small{color:#82978a;font-weight:400;margin-left:6px} + +/* lobby: a primary row, the game settings, then the rest grouped by what they are for */ +.actions{margin:26px 0 12px} +.setup{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 0 22px} +.setup select{border:1px solid var(--line);background:#0b1912;color:#eaf5ee;border-radius:12px;padding:.7rem .9rem;cursor:pointer} +.setup .ghost{padding:.7rem .9rem} +.groups{display:grid;gap:14px;margin:0 0 22px} +.group h3{margin:0 0 7px;font-size:.7rem;letter-spacing:.14em;text-transform:uppercase;color:#7c9385;font-weight:800} +.chips{display:flex;flex-wrap:wrap;gap:8px} +.chips button{padding:.55rem .95rem;font-size:.86rem} +.learn{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin:22px 0 0;padding:12px 14px;border:1px solid #2f5544;border-radius:14px;background:#0e2318} +.learn span{flex:1;min-width:200px;color:#bfe6cf;font-size:.9rem} +.learn button{padding:.55rem .9rem;font-size:.86rem} diff --git a/src/tutorial.js b/src/tutorial.js new file mode 100644 index 0000000..f0ce321 --- /dev/null +++ b/src/tutorial.js @@ -0,0 +1,30 @@ +// The first-run tutorial: five short steps, each one an ordinary drill with a plain explanation of the control it +// teaches, so a new player learns the game by doing it. Pure data and a few helpers; main.js plays the steps as drills. + +export const STEPS=[ + {drill:'straight-in',title:'Aim and shoot', + text:'Drag on the table to turn the cue. The dotted line shows where the ball will go: point it at the yellow ball and the pocket, then press Shoot (or Space).'}, + {drill:'across-the-table',title:'Power', + text:'The Power slider is how hard you hit. Harder is faster but harder to control. This ball is a long way off, so try about half power.'}, + {drill:'side-cut',title:'Cutting a ball', + text:'You rarely hit a ball straight on. Aim at the far edge of it to send it off at an angle toward the pocket; the second line shows where it will travel.'}, + {drill:'stop-shot',title:'Spin', + text:'The white circle beside Shoot is the cue ball. Tap low on it for backspin, high for follow-through. Here, hit low and centre so the cue ball stops where the yellow ball was.'}, + {drill:'bank-shot',title:'Bank shots', + text:'A ball can bounce off a cushion. Aim the ball at the rail, not at the pocket; the bent line shows the bank. Stuck? Press Show me.'} +] + +export const DONE_KEY='pool-masters:tutorial' +export const stepAt=i=>Number.isInteger(i)&&i>=0&&iNumber.isInteger(i)&&i>=0&&i`TUTORIAL ${i+1} OF ${STEPS.length}` + +// Whether to offer the tutorial to someone: only until they have done it or said no. +export const shouldOffer=stored=>stored==null||stored==='' + +// A drill, dressed as a tutorial step: the same layout, rule and hint, with the step's own words. +export function stepDrill(drills,i){ + const s=stepAt(i);if(!s)return null + const d=drills.find(x=>x.id===s.drill);if(!d)return null + return {...d,name:s.title,tip:s.text,label:label(i),tutorial:i} +} diff --git a/test/tutorial.test.js b/test/tutorial.test.js new file mode 100644 index 0000000..be34cf1 --- /dev/null +++ b/test/tutorial.test.js @@ -0,0 +1,43 @@ +import test from 'node:test';import assert from 'node:assert/strict' +import fs from 'node:fs' +import {STEPS,stepAt,hasNext,label,shouldOffer,stepDrill,DONE_KEY} from '../src/tutorial.js' +import {DRILLS,attempt} from '../src/drills.js' +import {setTableSize} from '../src/table.js' +setTableSize(7) + +test('every step teaches with a real drill, in plain words, and can be solved',()=>{ + assert.equal(STEPS.length,5) + for(let i=0;ix.id===STEPS[i].drill) + assert.ok(d&&src,`step ${i}: drill ${STEPS[i].drill} exists`) + assert.equal(d.tutorial,i);assert.equal(d.tip,STEPS[i].text);assert.equal(d.name,STEPS[i].title);assert.equal(d.label,`TUTORIAL ${i+1} OF 5`) + assert.equal(d.id,src.id,'same id, so progress is the drill\'s own');assert.deepEqual(d.layout,src.layout);assert.deepEqual(d.win,src.win) + assert.equal(attempt(d,d.hint).ok,true,`${d.id}: Show me solves it`) + assert.ok(STEPS[i].text.length>40&&STEPS[i].text.length<260,'a sentence or two') + } + assert.equal(new Set(STEPS.map(s=>s.drill)).size,STEPS.length,'no drill twice') +}) + +test('the steps run in order, the last one has no next, and out-of-range asks are refused',()=>{ + assert.equal(hasNext(0),true);assert.equal(hasNext(STEPS.length-2),true);assert.equal(hasNext(STEPS.length-1),false) + for(const bad of [-1,5,1.5,null,undefined,'2',NaN])assert.equal(hasNext(bad),false,String(bad)) + assert.equal(stepAt(0).title,'Aim and shoot');assert.equal(stepAt(5),null);assert.equal(stepAt(-1),null);assert.equal(stepDrill(DRILLS,9),null) + assert.equal(label(2),'TUTORIAL 3 OF 5') +}) + +test('it is offered to a new player once, and never again after they finish or decline',()=>{ + assert.equal(shouldOffer(null),true);assert.equal(shouldOffer(undefined),true);assert.equal(shouldOffer(''),true) + assert.equal(shouldOffer('done'),false);assert.equal(shouldOffer('skipped'),false) + assert.match(DONE_KEY,/^pool-masters:/) +}) + +test('the lobby is grouped, every control keeps one id, and nothing the code binds went missing',()=>{ + const src=fs.readFileSync(new URL('../src/main.js',import.meta.url),'utf8') + const a=src.indexOf('
',a)) + const ids=[...lobby.matchAll(/ id="([^"]+)"/g)].map(m=>m[1]) + assert.equal(new Set(ids).size,ids.length,'no duplicate ids') + for(const id of ['quick','practice','hotseat','game-mode','ai-level','obstacles','house','tutorial','daily','drills','challenges','puzzles','career','rogue','code','join','create','learn','learn-go','learn-skip','rooms','leaders','refresh'])assert.ok(ids.includes(id),`#${id} is on the lobby`) + for(const g of ['Sharpen your game','Solo runs'])assert.ok(lobby.includes(g),g) + // every button the old lobby had is still reachable, and each is bound + for(const id of ['quick','practice','hotseat','house','daily','drills','challenges','puzzles','career','rogue','tutorial'])assert.ok(src.includes(`$('#${id}').onclick`),`#${id} has a handler`) +}) diff --git a/tools/build-roadmap.mjs b/tools/build-roadmap.mjs index 8dfdf1d..7e14b3d 100644 --- a/tools/build-roadmap.mjs +++ b/tools/build-roadmap.mjs @@ -73,6 +73,7 @@ const shippedNew=[ ['Aim snapping and a follow-the-shot camera','Aim snaps to the middle of a pocket (or a one-cushion bank) when you are close, can be turned off, and the 3D camera can follow the ball with an adjustable eye height.'], ['Rogue Pool','A solo roguelike run: clear each table within its shots, take one of three random upgrades (wider pockets, extra shots, lives, jump charges, a scratch shield, a second wind), and lose a life when you run out. The same seed is the same run.'], ['Chaos Pool','A scored game to 15 with a random twist dealt before every shot: a bonus pocket worth three, a bomb ball that blasts its neighbours, or a gravity well that bends the balls. The host deals it, so both players see the same one.'], + ['A tidier lobby and a first-run tutorial','The lobby is grouped: a primary row (Find a game, Practice vs AI, Two players on one device), the game settings (game, AI, table, house rules), then Sharpen your game (How to play, Daily shot, Drills and trick shots, Challenges, Puzzle maker) and Solo runs (Career, Rogue Pool). A new player is offered a five-step tutorial once: aim, power, cutting, spin and banks, each an ordinary drill with a plain explanation and a Show me.'], ['Curated trick shots','Twenty tables that one shot clears completely (eight with two balls, eight with three, four with four), under Drills. Each has a Show me button that sets up and plays the proven shot. Every one was found by searching the real physics, and the tests replay each through the physics, so a change that breaks one fails the build.'], ['Obstacle tables','A table option in the lobby for practice and two players on one device: Bumpers, Walls, Portals or the Gauntlet. Balls bounce off bumpers and walls and come out of the paired portal going the same way; a jump shot hops a bumper or wall. The AI, the solver and the physics all see them. Not yet: online tables, and placing your own in the puzzle maker.'], ['Puzzle maker','Arrange a table, pick the ball to pot, and the computer searches the real physics for a shot. Only proven-solvable tables can be shared, and a puzzle link re-proves itself when it is opened.']