diff --git a/apps/rail-drop/.claude/launch.json b/apps/rail-drop/.claude/launch.json new file mode 100644 index 000000000..d877c0544 --- /dev/null +++ b/apps/rail-drop/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "rail-drop", + "runtimeExecutable": "npx", + "runtimeArgs": ["vite", "dev", "--host", "--port", "3002"], + "port": 3002 + } + ] +} diff --git a/apps/rail-drop/lingui.config.ts b/apps/rail-drop/lingui.config.ts new file mode 100644 index 000000000..9f18a0be2 --- /dev/null +++ b/apps/rail-drop/lingui.config.ts @@ -0,0 +1,3 @@ +import config from 'config-lingui'; + +export default config; diff --git a/apps/rail-drop/package.json b/apps/rail-drop/package.json new file mode 100644 index 000000000..632dc792f --- /dev/null +++ b/apps/rail-drop/package.json @@ -0,0 +1,48 @@ +{ + "name": "rail-drop", + "version": "0.0.0", + "private": true, + "license": "MIT", + "scripts": { + "format": "prettier --write --ignore-path=../../.prettierignore .", + "lint": "eslint \"src\"", + "dev": "vite dev --host --port 3002", + "build": "vite build", + "preview": "vite preview" + }, + "type": "module", + "devDependencies": { + "eslint": "9.21.0", + "eslint-config-custom": "workspace:*", + "config-ts": "workspace:*", + "config-vite": "workspace:*", + "config-svelte": "workspace:*", + "config-lingui": "workspace:*", + "@sveltejs/vite-plugin-svelte": "5.0.3" + }, + "dependencies": { + "lodash": "4.17.16", + "@types/lodash": "4.17.16", + "svelte": "5.20.5", + "vite": "6.2.0", + "@sveltejs/kit": "2.17.3", + "@lingui/core": "5.2.0", + "envs": "workspace:*", + "rgs-requests": "workspace:*", + "pixi-svelte": "workspace:*", + "state-shared": "workspace:*", + "constants-shared": "workspace:*", + "components-shared": "workspace:*", + "components-layout": "workspace:*", + "components-ui-html": "workspace:*", + "components-pixi": "workspace:*", + "components-ui-pixi": "workspace:*", + "utils-event-emitter": "workspace:*", + "utils-shared": "workspace:*", + "utils-xstate": "workspace:*", + "utils-book": "workspace:*", + "utils-bet": "workspace:*", + "utils-layout": "workspace:*", + "utils-sound": "workspace:*" + } +} diff --git a/apps/rail-drop/src/app.css b/apps/rail-drop/src/app.css new file mode 100644 index 000000000..38ec993d2 --- /dev/null +++ b/apps/rail-drop/src/app.css @@ -0,0 +1,2 @@ +*, *::before, *::after { box-sizing: border-box; } +body { margin: 0; padding: 0; background: #0a0a14; } diff --git a/apps/rail-drop/src/app.html b/apps/rail-drop/src/app.html new file mode 100644 index 000000000..83537c1aa --- /dev/null +++ b/apps/rail-drop/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/rail-drop/src/components/EnableGameActor.svelte b/apps/rail-drop/src/components/EnableGameActor.svelte new file mode 100644 index 000000000..85a3dc6a3 --- /dev/null +++ b/apps/rail-drop/src/components/EnableGameActor.svelte @@ -0,0 +1,26 @@ + diff --git a/apps/rail-drop/src/components/Game.svelte b/apps/rail-drop/src/components/Game.svelte new file mode 100644 index 000000000..78db6423c --- /dev/null +++ b/apps/rail-drop/src/components/Game.svelte @@ -0,0 +1,379 @@ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {#each ZONE_KEYS as zk} + {@const z = ZONES[zk]} + {@const isActive = activeZone === zk} + {@const bw = (z.rows + 1) * z.slotWidth} + {@const bh = z.rows * z.rowHeight + SLOT_HEIGHT} + {@const bx = z.centerX - bw / 2} + {@const by = z.boardTopY} + {@const activeSlot = (phase === 'landed' && activeZone === zk) ? slotIndex : -1} + + + + + + + + + + + + + + {z.rows} ROWS + + + + {#each getPins(zk) as pin} + {@const isHit = isActive && hitPinRow === pin.row} + + {/each} + + + {#each getSlots(zk) as slot} + {@const isLanded = activeSlot === slot.k} + {@const color = slotColor(slot.mult)} + + + {fmtMult(slot.mult, z.slotWidth)}{z.slotWidth >= 25 ? '×' : ''} + + {/each} + {/each} + + + {#if ballVisible} + + {/if} + + + + RISK + + {#each RISK_LEVELS as level, i} + {@const isSelected = riskLevel === level} + {@const btnX = 12 + i * 68} + + { if (phase === 'idle') riskLevel = level; }} style="cursor:{phase === 'idle' ? 'pointer' : 'default'}"> + + + {RISK_LABELS[level]} + + + {/each} + + + + ZONE ROWS + + {#each ZONE_KEYS as zk, i} + {@const z = ZONES[zk]} + {@const isActive = activeZone === zk} + + Z{zk} {z.rows}r [{MULTIPLIERS[z.rows as 8 | 12 | 16][riskLevel][0]}× — {MULTIPLIERS[z.rows as 8 | 12 | 16][riskLevel][Math.floor(z.rows / 2)]}× — {MULTIPLIERS[z.rows as 8 | 12 | 16][riskLevel][z.rows]}×] + + {/each} + + + {#if showWin} + + + + + + + + {multiplier}× MULTIPLIER + + + + + ${(finalAmount / 100).toFixed(2)} + + {/if} + diff --git a/apps/rail-drop/src/game/actor.ts b/apps/rail-drop/src/game/actor.ts new file mode 100644 index 000000000..c20008141 --- /dev/null +++ b/apps/rail-drop/src/game/actor.ts @@ -0,0 +1,22 @@ +import { stateBet } from 'state-shared'; +import { createPrimaryMachines, createIntermediateMachines, createGameActor } from 'utils-xstate'; + +import type { Bet } from './typesBookEvent'; +import { stateXstateDerived } from './stateXstate'; +import { playBet, convertToResumableBet } from './utils'; + +const primaryMachines = createPrimaryMachines({ + onResumeGameActive: (betToResume) => convertToResumableBet(betToResume), + onResumeGameInactive: () => {}, + onNewGameStart: async () => { + if ((stateBet.isTurbo && stateXstateDerived.isAutoBetting()) || stateBet.isSpaceHold) return; + stateBet.winBookEventAmount = 0; + }, + onNewGameError: () => {}, + onPlayGame: async (bet) => await playBet(bet), + checkIsBonusGame: () => false, +}); + +const intermediateMachines = createIntermediateMachines(primaryMachines); + +export const gameActor = createGameActor(intermediateMachines); diff --git a/apps/rail-drop/src/game/bookEventHandlerMap.ts b/apps/rail-drop/src/game/bookEventHandlerMap.ts new file mode 100644 index 000000000..ace112460 --- /dev/null +++ b/apps/rail-drop/src/game/bookEventHandlerMap.ts @@ -0,0 +1,15 @@ +import type { BookEventHandlerMap } from 'utils-book'; + +import { eventEmitter } from './eventEmitter'; +import type { BookEvent, BookEventOfType, BookEventContext } from './typesBookEvent'; + +export const bookEventHandlerMap: BookEventHandlerMap = { + drop: async (bookEvent: BookEventOfType<'drop'>) => { + await eventEmitter.broadcastAsync({ type: 'reset' }); + await eventEmitter.broadcastAsync({ type: 'drop', data: bookEvent }); + }, + finalWin: async (bookEvent: BookEventOfType<'finalWin'>) => { + await eventEmitter.broadcastAsync({ type: 'finalWin', data: bookEvent }); + }, + createBonusSnapshot: async () => {}, +}; diff --git a/apps/rail-drop/src/game/config.ts b/apps/rail-drop/src/game/config.ts new file mode 100644 index 000000000..6258e56a5 --- /dev/null +++ b/apps/rail-drop/src/game/config.ts @@ -0,0 +1,21 @@ +export default { + providerName: 'sample_provider', + gameName: 'Rail Drop', + gameID: 'rail_drop', + rtp: 0.99, + numReels: 0, + numRows: [] as number[], + betModes: { + base: { + cost: 1.0, + feature: true, + buyBonus: false, + rtp: 0.99, + max_win: 1000, + }, + }, + symbols: {} as Record, + paddingReels: { + basegame: '', + } as const, +}; diff --git a/apps/rail-drop/src/game/constants.ts b/apps/rail-drop/src/game/constants.ts new file mode 100644 index 000000000..4b131d651 --- /dev/null +++ b/apps/rail-drop/src/game/constants.ts @@ -0,0 +1,60 @@ +import type { RiskLevel } from './types'; + +export const RAIL_Y = 90; +export const RAIL_START_X = 60; +export const BALL_R = 11; +export const SLOT_HEIGHT = 48; + +// Zone geometry: 3 plinko boards hanging below the rail. +// Zone 1 (near, 8 rows): conservative multipliers. +// Zone 2 (mid, 12 rows): higher variance. +// Zone 3 (far, 16 rows): extreme multipliers. +export const ZONES = { + 1: { rows: 8, centerX: 280, boardTopY: 150, slotWidth: 34, rowHeight: 46, pinRadius: 5 }, + 2: { rows: 12, centerX: 780, boardTopY: 150, slotWidth: 28, rowHeight: 40, pinRadius: 4 }, + 3: { rows: 16, centerX: 1310, boardTopY: 150, slotWidth: 22, rowHeight: 35, pinRadius: 3 }, +} as const; + +// Real Stake Plinko multipliers (symmetric, ~99% RTP per zone). +// Indexed by [rows][riskLevel][slotIndex 0..rows]. +export const MULTIPLIERS: Record<8 | 12 | 16, Record> = { + 8: { + low: [5.6, 2.1, 1.1, 1.0, 0.5, 1.0, 1.1, 2.1, 5.6], + medium: [13, 3, 1.3, 0.7, 0.4, 0.7, 1.3, 3, 13 ], + high: [29, 4, 1.5, 0.3, 0.2, 0.3, 1.5, 4, 29 ], + }, + 12: { + low: [10, 3, 1.6, 1.4, 1.1, 1.0, 0.5, 1.0, 1.1, 1.4, 1.6, 3, 10 ], + medium: [33, 11, 4, 2, 1.1, 0.6, 0.3, 0.6, 1.1, 2, 4, 11, 33 ], + high: [170, 24, 8.1, 2, 0.7, 0.2, 0.2, 0.2, 0.7, 2, 8.1, 24, 170], + }, + 16: { + low: [16, 9, 2, 1.4, 1.4, 1.2, 1.1, 1.0, 0.5, 1.0, 1.1, 1.2, 1.4, 1.4, 2, 9, 16 ], + medium: [110, 41, 10, 5, 3, 1.5, 1.0, 0.5, 0.3, 0.5, 1.0, 1.5, 3, 5, 10, 41, 110 ], + high: [1000, 130, 26, 9, 4, 2, 0.2, 0.2, 0.2, 0.2, 0.2, 2, 4, 9, 26, 130, 1000], + }, +}; + +// Slot color by multiplier value (symmetric: edges = hot, center = cool). +export function slotColor(mult: number): string { + if (mult >= 100) return '#e53935'; + if (mult >= 20) return '#e64a19'; + if (mult >= 10) return '#f57c00'; + if (mult >= 5) return '#f9a825'; + if (mult >= 2) return '#558b2f'; + if (mult >= 1) return '#00695c'; + if (mult >= 0.5) return '#1565c0'; + return '#4527a0'; +} + +// Ball roll duration per zone (ms). +export const ROLL_DURATION: Record<1 | 2 | 3, number> = { + 1: 700, + 2: 1100, + 3: 1500, +}; + +export const DROP_DURATION = 280; +export const BOUNCE_DURATION = 130; +export const BOUNCE_PAUSE = 25; +export const WIN_DISPLAY_MS = 2800; diff --git a/apps/rail-drop/src/game/context.ts b/apps/rail-drop/src/game/context.ts new file mode 100644 index 000000000..37ef88fc4 --- /dev/null +++ b/apps/rail-drop/src/game/context.ts @@ -0,0 +1,28 @@ +import { setContextEventEmitter, getContextEventEmitter } from 'utils-event-emitter'; +import { setContextXstate, getContextXstate } from 'utils-xstate'; +import { setContextLayout, getContextLayout } from 'utils-layout'; +import { setContextApp, getContextApp } from 'pixi-svelte'; + +import { eventEmitter, type EmitterEvent } from './eventEmitter'; +import { stateXstate, stateXstateDerived } from './stateXstate'; +import { stateLayout, stateLayoutDerived } from './stateLayout'; +import { stateApp } from './stateApp'; +import { stateGame, stateGameDerived } from './stateGame.svelte'; +import { i18nDerived } from '../i18n/i18nDerived'; + +export const setContext = () => { + setContextEventEmitter({ eventEmitter }); + setContextXstate({ stateXstate, stateXstateDerived }); + setContextLayout({ stateLayout, stateLayoutDerived }); + setContextApp({ stateApp }); +}; + +export const getContext = () => ({ + ...getContextEventEmitter(), + ...getContextLayout(), + ...getContextXstate(), + ...getContextApp(), + stateGame, + stateGameDerived, + i18nDerived, +}); diff --git a/apps/rail-drop/src/game/eventEmitter.ts b/apps/rail-drop/src/game/eventEmitter.ts new file mode 100644 index 000000000..3e9e13cf1 --- /dev/null +++ b/apps/rail-drop/src/game/eventEmitter.ts @@ -0,0 +1,14 @@ +import { createEventEmitter } from 'utils-event-emitter'; +import type { EmitterEventHotKey } from 'components-shared'; +import type { EmitterEventUi } from 'components-ui-pixi'; +import type { EmitterEventModal } from 'components-ui-html'; + +import type { EmitterEventGame } from './typesEmitterEvent'; + +export type EmitterEvent = + | EmitterEventHotKey + | EmitterEventUi + | EmitterEventModal + | EmitterEventGame; + +export const { eventEmitter } = createEventEmitter(); diff --git a/apps/rail-drop/src/game/sound.ts b/apps/rail-drop/src/game/sound.ts new file mode 100644 index 000000000..1a8e6a788 --- /dev/null +++ b/apps/rail-drop/src/game/sound.ts @@ -0,0 +1,21 @@ +import { createSound } from 'utils-sound'; + +export type MusicName = + | 'bgm_main' + | 'bgm_winlevel_big' + | 'bgm_winlevel_superwin' + | 'bgm_winlevel_mega' + | 'bgm_winlevel_epic' + | 'bgm_winlevel_max'; + +export type SoundEffectName = + | 'sfx_ball_roll' + | 'sfx_ball_drop' + | 'sfx_pin_hit' + | 'sfx_slot_land' + | 'sfx_big_win'; + +export type SoundName = MusicName | SoundEffectName; + +const sound = createSound(); +export { sound }; diff --git a/apps/rail-drop/src/game/stateApp.ts b/apps/rail-drop/src/game/stateApp.ts new file mode 100644 index 000000000..0e66ed5c2 --- /dev/null +++ b/apps/rail-drop/src/game/stateApp.ts @@ -0,0 +1,3 @@ +import { createApp } from 'pixi-svelte'; + +export const { stateApp } = createApp({ assets: {} }); diff --git a/apps/rail-drop/src/game/stateGame.svelte.ts b/apps/rail-drop/src/game/stateGame.svelte.ts new file mode 100644 index 000000000..d573bb585 --- /dev/null +++ b/apps/rail-drop/src/game/stateGame.svelte.ts @@ -0,0 +1,25 @@ +import { createGetWinLevelDataByWinLevelAlias } from 'utils-shared/winLevel'; + +import type { GamePhase, RiskLevel, Zone } from './types'; +import { winLevelMap } from './winLevelMap'; + +export const stateGame = $state({ + phase: 'idle' as GamePhase, + riskLevel: 'medium' as RiskLevel, + activeZone: 0 as 0 | Zone, + multiplier: 0, + finalAmount: 0, +}); + +export const resetGame = () => { + stateGame.phase = 'idle'; + stateGame.activeZone = 0; + stateGame.multiplier = 0; + stateGame.finalAmount = 0; +}; + +export const { getWinLevelDataByWinLevelAlias } = createGetWinLevelDataByWinLevelAlias({ + winLevelMap, +}); + +export const stateGameDerived = { getWinLevelDataByWinLevelAlias }; diff --git a/apps/rail-drop/src/game/stateLayout.ts b/apps/rail-drop/src/game/stateLayout.ts new file mode 100644 index 000000000..e85dc5fda --- /dev/null +++ b/apps/rail-drop/src/game/stateLayout.ts @@ -0,0 +1,14 @@ +import { createLayout } from 'utils-layout'; + +export const { stateLayout, stateLayoutDerived } = createLayout({ + backgroundRatio: { + normal: 16 / 9, + portrait: 9 / 16, + }, + mainSizesMap: { + desktop: { width: 1600, height: 900 }, + tablet: { width: 1200, height: 900 }, + landscape: { width: 1600, height: 900 }, + portrait: { width: 900, height: 1600 }, + }, +}); diff --git a/apps/rail-drop/src/game/stateXstate.ts b/apps/rail-drop/src/game/stateXstate.ts new file mode 100644 index 000000000..650293518 --- /dev/null +++ b/apps/rail-drop/src/game/stateXstate.ts @@ -0,0 +1,3 @@ +import { createXstate } from 'utils-xstate'; + +export const { stateXstate, stateXstateDerived } = createXstate(); diff --git a/apps/rail-drop/src/game/types.ts b/apps/rail-drop/src/game/types.ts new file mode 100644 index 000000000..261e64770 --- /dev/null +++ b/apps/rail-drop/src/game/types.ts @@ -0,0 +1,9 @@ +import type config from './config'; + +export type BetMode = keyof typeof config.betModes; +export type GameType = keyof typeof config.paddingReels; +export type RiskLevel = 'low' | 'medium' | 'high'; +export type Zone = 1 | 2 | 3; +export type BallDirection = 'L' | 'R'; + +export type GamePhase = 'idle' | 'rolling' | 'dropping' | 'bouncing' | 'landed'; diff --git a/apps/rail-drop/src/game/typesBookEvent.ts b/apps/rail-drop/src/game/typesBookEvent.ts new file mode 100644 index 000000000..859de6155 --- /dev/null +++ b/apps/rail-drop/src/game/typesBookEvent.ts @@ -0,0 +1,34 @@ +import type { BetType } from 'rgs-requests'; +import type { RiskLevel, Zone, BallDirection } from './types'; + +// Server resolves zone, path through pins, and final multiplier. +type BookEventDrop = { + index: number; + type: 'drop'; + zone: Zone; + riskLevel: RiskLevel; + path: BallDirection[]; // length === zone.rows, one L/R per pin row + slotIndex: number; // 0..zone.rows (= number of R's in path) + multiplier: number; +}; + +type BookEventFinalWin = { + index: number; + type: 'finalWin'; + amount: number; // in cents (bet × multiplier) +}; + +type BookEventCreateBonusSnapshot = { + index: number; + type: 'createBonusSnapshot'; + bookEvents: BookEvent[]; +}; + +export type BookEvent = + | BookEventDrop + | BookEventFinalWin + | BookEventCreateBonusSnapshot; + +export type Bet = BetType; +export type BookEventOfType = Extract; +export type BookEventContext = { bookEvents: BookEvent[] }; diff --git a/apps/rail-drop/src/game/typesEmitterEvent.ts b/apps/rail-drop/src/game/typesEmitterEvent.ts new file mode 100644 index 000000000..076a66b58 --- /dev/null +++ b/apps/rail-drop/src/game/typesEmitterEvent.ts @@ -0,0 +1,18 @@ +import type { BookEventOfType } from './typesBookEvent'; + +export type EmitterEventDrop = { + type: 'drop'; + data: BookEventOfType<'drop'>; +}; + +export type EmitterEventFinalWin = { + type: 'finalWin'; + data: BookEventOfType<'finalWin'>; +}; + +export type EmitterEventReset = { type: 'reset' }; + +export type EmitterEventGame = + | EmitterEventDrop + | EmitterEventFinalWin + | EmitterEventReset; diff --git a/apps/rail-drop/src/game/utils.ts b/apps/rail-drop/src/game/utils.ts new file mode 100644 index 000000000..62cfde97e --- /dev/null +++ b/apps/rail-drop/src/game/utils.ts @@ -0,0 +1,30 @@ +import { stateBet } from 'state-shared'; +import { createPlayBookUtils } from 'utils-book'; + +import { eventEmitter } from './eventEmitter'; +import type { Bet, BookEventOfType } from './typesBookEvent'; +import { bookEventHandlerMap } from './bookEventHandlerMap'; + +export const { playBookEvent, playBookEvents } = createPlayBookUtils({ bookEventHandlerMap }); + +export const playBet = async (bet: Bet) => { + stateBet.winBookEventAmount = 0; + await playBookEvents(bet.state); + eventEmitter.broadcast({ type: 'stopButtonEnable' }); +}; + +const SNAPSHOT_EVENT_TYPES: string[] = []; + +export const convertToResumableBet = (betToResume: Bet) => { + const resumingIndex = Number(betToResume.event); + const before = betToResume.state.filter((_, i) => i < resumingIndex); + const after = betToResume.state.filter((_, i) => i >= resumingIndex); + + const snapshot: BookEventOfType<'createBonusSnapshot'> = { + index: 0, + type: 'createBonusSnapshot', + bookEvents: before.filter((e) => SNAPSHOT_EVENT_TYPES.includes(e.type)), + }; + + return { ...betToResume, state: [snapshot, ...after] }; +}; diff --git a/apps/rail-drop/src/game/winLevelMap.ts b/apps/rail-drop/src/game/winLevelMap.ts new file mode 100644 index 000000000..67b207c0d --- /dev/null +++ b/apps/rail-drop/src/game/winLevelMap.ts @@ -0,0 +1,19 @@ +import { SECOND } from 'constants-shared/time'; + +export const winLevelMap = { + 1: { level: 1, alias: 'zero', type: 'small', text: null, presentDuration: 0, sound: { sfx: undefined, bgm: undefined }, animation: undefined }, + 2: { level: 2, alias: 'standard', type: 'small', text: null, presentDuration: 0.6 * SECOND, sound: { sfx: undefined, bgm: undefined }, animation: undefined }, + 3: { level: 3, alias: 'small', type: 'small', text: null, presentDuration: 1 * SECOND, sound: { sfx: undefined, bgm: undefined }, animation: undefined }, + 4: { level: 4, alias: 'nice', type: 'medium', text: null, presentDuration: 1.5 * SECOND, sound: { sfx: undefined, bgm: undefined }, animation: undefined }, + 5: { level: 5, alias: 'substantial',type: 'medium', text: null, presentDuration: 2 * SECOND, sound: { sfx: undefined, bgm: undefined }, animation: undefined }, + 6: { level: 6, alias: 'big', type: 'big', text: 'BIG WIN', presentDuration: 6 * SECOND, sound: { sfx: undefined, bgm: 'bgm_winlevel_big' }, animation: undefined }, + 7: { level: 7, alias: 'superwin', type: 'big', text: 'SUPER WIN', presentDuration: 18 * SECOND, sound: { sfx: undefined, bgm: 'bgm_winlevel_superwin'}, animation: undefined }, + 8: { level: 8, alias: 'mega', type: 'big', text: 'MEGA WIN', presentDuration: 20 * SECOND, sound: { sfx: undefined, bgm: 'bgm_winlevel_mega' }, animation: undefined }, + 9: { level: 9, alias: 'epic', type: 'big', text: 'EPIC WIN!', presentDuration: 26 * SECOND, sound: { sfx: undefined, bgm: 'bgm_winlevel_epic' }, animation: undefined }, + 10: { level: 10, alias: 'max', type: 'big', text: 'MAX WIN', presentDuration: 32 * SECOND, sound: { sfx: undefined, bgm: 'bgm_winlevel_max' }, animation: undefined }, +} as const; + +export type WinLevelMap = typeof winLevelMap; +export type WinLevel = keyof typeof winLevelMap; +export type WinLevelData = WinLevelMap[WinLevel]; +export type WinLevelAlias = WinLevelData['alias']; diff --git a/apps/rail-drop/src/hooks.server.ts b/apps/rail-drop/src/hooks.server.ts new file mode 100644 index 000000000..53222ebe7 --- /dev/null +++ b/apps/rail-drop/src/hooks.server.ts @@ -0,0 +1,8 @@ +import { dev } from '$app/environment'; + +export function handle({ event, resolve }) { + if (dev && event.url.pathname === '/.well-known/appspecific/com.chrome.devtools.json') { + return new Response(undefined, { status: 404 }); + } + return resolve(event); +} diff --git a/apps/rail-drop/src/i18n/i18nDerived.ts b/apps/rail-drop/src/i18n/i18nDerived.ts new file mode 100644 index 000000000..811452d5e --- /dev/null +++ b/apps/rail-drop/src/i18n/i18nDerived.ts @@ -0,0 +1,9 @@ +import { stateI18nDerived } from 'state-shared'; +import { i18nDerived as i18nDerivedUiPixi } from 'components-ui-pixi'; +import { i18nDerived as i18nDerivedUiHtml } from 'components-ui-html'; + +export const i18nDerived = { + ...i18nDerivedUiPixi, + ...i18nDerivedUiHtml, + home: () => stateI18nDerived.translate('HOME'), +}; diff --git a/apps/rail-drop/src/i18n/messagesMap/en.ts b/apps/rail-drop/src/i18n/messagesMap/en.ts new file mode 100644 index 000000000..0d26bf929 --- /dev/null +++ b/apps/rail-drop/src/i18n/messagesMap/en.ts @@ -0,0 +1,3 @@ +export default { + HOME: 'HOME', +}; diff --git a/apps/rail-drop/src/i18n/messagesMap/index.ts b/apps/rail-drop/src/i18n/messagesMap/index.ts new file mode 100644 index 000000000..fb0f0075d --- /dev/null +++ b/apps/rail-drop/src/i18n/messagesMap/index.ts @@ -0,0 +1,11 @@ +import { mergeMessagesMaps } from 'utils-shared/i18n'; +import { messagesMap as messagesMapUiPixi } from 'components-ui-pixi'; +import { messagesMap as messagesMapUiHtml } from 'components-ui-html'; + +import en from './en'; +import zh from './zh'; + +const messagesMapGame = { en, zh }; +const messagesMap = mergeMessagesMaps([messagesMapGame, messagesMapUiPixi, messagesMapUiHtml]); + +export default messagesMap; diff --git a/apps/rail-drop/src/i18n/messagesMap/zh.ts b/apps/rail-drop/src/i18n/messagesMap/zh.ts new file mode 100644 index 000000000..ad40c9626 --- /dev/null +++ b/apps/rail-drop/src/i18n/messagesMap/zh.ts @@ -0,0 +1,3 @@ +export default { + HOME: '主页', +}; diff --git a/apps/rail-drop/src/routes/+layout.svelte b/apps/rail-drop/src/routes/+layout.svelte new file mode 100644 index 000000000..a3077c987 --- /dev/null +++ b/apps/rail-drop/src/routes/+layout.svelte @@ -0,0 +1,34 @@ + + + + + + + + + + + (showLoader = true)} /> + +{#if showLoader} + +{/if} + +{@render props.children()} diff --git a/apps/rail-drop/src/routes/+layout.ts b/apps/rail-drop/src/routes/+layout.ts new file mode 100644 index 000000000..a72344cb8 --- /dev/null +++ b/apps/rail-drop/src/routes/+layout.ts @@ -0,0 +1,3 @@ +export const prerender = true; +export const ssr = false; +export const trailingSlash = 'ignore'; diff --git a/apps/rail-drop/src/routes/+page.svelte b/apps/rail-drop/src/routes/+page.svelte new file mode 100644 index 000000000..5bf03f4a7 --- /dev/null +++ b/apps/rail-drop/src/routes/+page.svelte @@ -0,0 +1 @@ + diff --git a/apps/rail-drop/src/stories/data/sampleBooks.ts b/apps/rail-drop/src/stories/data/sampleBooks.ts new file mode 100644 index 000000000..56c5cf7c2 --- /dev/null +++ b/apps/rail-drop/src/stories/data/sampleBooks.ts @@ -0,0 +1,63 @@ +/* Sample books for Storybook / manual playback without a live RGS. + * + * Mechanics: + * Ball rolls along rail, drops into one of 3 plinko zones: + * Zone 1: 8 rows — conservative multipliers + * Zone 2: 12 rows — medium variance + * Zone 3: 16 rows — extreme multipliers + * slotIndex = number of R's in path (0 = leftmost, rows = rightmost) + * finalWin.amount is in cents (bet = 100c) + */ + +import type { BookEvent } from '../../game/typesBookEvent'; + +// Zone 1, low risk, center slot → 0.5× (loss) — medium risk +export const bookZone1Loss: BookEvent[] = [ + { index: 0, type: 'drop', zone: 1, riskLevel: 'medium', + path: ['R','L','R','L','R','L','R','L'], slotIndex: 4, multiplier: 0.4 }, + { index: 1, type: 'finalWin', amount: 40 }, +]; + +// Zone 1, low risk, edge slot → 5.6× +export const bookZone1Edge: BookEvent[] = [ + { index: 0, type: 'drop', zone: 1, riskLevel: 'low', + path: ['L','L','L','L','L','L','L','L'], slotIndex: 0, multiplier: 5.6 }, + { index: 1, type: 'finalWin', amount: 560 }, +]; + +// Zone 2, medium risk, center → 0.3× +export const bookZone2Center: BookEvent[] = [ + { index: 0, type: 'drop', zone: 2, riskLevel: 'medium', + path: ['R','R','L','R','L','R','L','L','R','L','R','L'], slotIndex: 6, multiplier: 0.3 }, + { index: 1, type: 'finalWin', amount: 30 }, +]; + +// Zone 2, high risk, left edge → 170× +export const bookZone2BigWin: BookEvent[] = [ + { index: 0, type: 'drop', zone: 2, riskLevel: 'high', + path: ['L','L','L','L','L','L','L','L','L','L','L','L'], slotIndex: 0, multiplier: 170 }, + { index: 1, type: 'finalWin', amount: 17000 }, +]; + +// Zone 3, medium risk, near-edge → 41× +export const bookZone3MedWin: BookEvent[] = [ + { index: 0, type: 'drop', zone: 3, riskLevel: 'medium', + path: ['L','L','L','L','L','L','L','L','L','L','L','L','L','L','L','R'], slotIndex: 1, multiplier: 41 }, + { index: 1, type: 'finalWin', amount: 4100 }, +]; + +// Zone 3, high risk, right edge → 1000× (MAX WIN) +export const bookZone3MaxWin: BookEvent[] = [ + { index: 0, type: 'drop', zone: 3, riskLevel: 'high', + path: ['R','R','R','R','R','R','R','R','R','R','R','R','R','R','R','R'], slotIndex: 16, multiplier: 1000 }, + { index: 1, type: 'finalWin', amount: 100000 }, +]; + +export const sampleBooks = { + zone1Loss: bookZone1Loss, + zone1Edge: bookZone1Edge, + zone2Center: bookZone2Center, + zone2BigWin: bookZone2BigWin, + zone3MedWin: bookZone3MedWin, + zone3MaxWin: bookZone3MaxWin, +}; diff --git a/apps/rail-drop/static/favicon.svg b/apps/rail-drop/static/favicon.svg new file mode 100644 index 000000000..4bfbcc818 --- /dev/null +++ b/apps/rail-drop/static/favicon.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/rail-drop/static/loader.gif b/apps/rail-drop/static/loader.gif new file mode 100644 index 000000000..4f94b9909 Binary files /dev/null and b/apps/rail-drop/static/loader.gif differ diff --git a/apps/rail-drop/static/stake-engine-loader.gif b/apps/rail-drop/static/stake-engine-loader.gif new file mode 100644 index 000000000..0a2e02ef2 Binary files /dev/null and b/apps/rail-drop/static/stake-engine-loader.gif differ diff --git a/apps/rail-drop/svelte.config.js b/apps/rail-drop/svelte.config.js new file mode 100644 index 000000000..8c4df3cd9 --- /dev/null +++ b/apps/rail-drop/svelte.config.js @@ -0,0 +1,4 @@ +// @ts-ignore +import config from 'config-svelte'; + +export default config(); diff --git a/apps/rail-drop/tsconfig.json b/apps/rail-drop/tsconfig.json new file mode 100644 index 000000000..6f0ea0c09 --- /dev/null +++ b/apps/rail-drop/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "config-ts/base.json", + "include": ["."], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/apps/rail-drop/vite.config.js b/apps/rail-drop/vite.config.js new file mode 100644 index 000000000..349ca771e --- /dev/null +++ b/apps/rail-drop/vite.config.js @@ -0,0 +1,4 @@ +// @ts-ignore +import config from 'config-vite'; + +export default config();