initial commit

This commit is contained in:
Brian Fertig 2026-02-26 19:58:56 -07:00
commit 2b6943c319
18 changed files with 1246 additions and 0 deletions

62
CLAUDE.md Normal file
View File

@ -0,0 +1,62 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**Virtue Slots** is a browser-based Phaser 3 slot machine game with a Christian religious theme. The full design specification is in `software.md`.
## Tech Stack
- **Phaser 3** (loaded via CDN or local script tag — no bundler)
- **Vanilla JavaScript with ES6 modules** (`import`/`export`)
- **No build step, no webpack/vite/bundler** — files are served directly
- **1600x900 canvas**, scaled to the user's viewport
## Running the Game
Serve the files over HTTP (browsers block ES6 module imports from `file://`):
```bash
python3 -m http.server 8080
# or
npx serve .
```
Then open `http://localhost:8080` in a browser.
## Architecture
The game uses ES6 modules with direct object references — no bundler required. Structure files as Phaser Scenes or plain classes, each in its own file, exported and imported directly.
Key architecture decisions from `software.md`:
- **No web packager** — all JS must be loadable via `<script type="module">` or Phaser's own loader
- **Modular and scalable** — each symbol, scene, and UI component should be its own class/file
- **Vector graphics first** — use Phaser's Graphics API as placeholders; sprites can replace them later
### Game Structure (from spec)
- **UI layout**: Three boxes at top (Your Funds / The Lord / Sin), slot reels in center, wide message/spin area at bottom
- **Economy**: Player starts with $1000; spins cost $50; wins split 60% player / 40% to "The Lord" (tithing)
- **Symbols**: 10 religious Christian symbols (baby jesus, cross, jesus on cross, crown of thorns, halo, etc.) — holier symbols yield higher payouts on match
- **Win rate**: ~1 in 15 spins
- **Win animation**: Screen brightens, gold/money icons rise to player and lord funds (angel-ascending-with-cash effect)
- **Loss animation**: Devil carries spent money up to the Sin box; message "Thou Hath Sinned." + "Redeem Yourself!" near spin button
- **Controls**: Click Spin button or press Space Bar
### Suggested Scene/File Layout
```
index.html — Loads Phaser and main.js as type="module"
main.js — Phaser game config, registers scenes
scenes/
BootScene.js — Asset preload
GameScene.js — Main gameplay
UIScene.js — Overlay HUD (funds display, message box)
objects/
Reel.js — Single reel logic and animation
SlotMachine.js — Orchestrates 3 reels, spin/result logic
Symbol.js — Symbol definitions, payout values
WinAnimation.js — Holy win effect
LossAnimation.js — Devil/sin effect
```

BIN
assets/symbol_sprites.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

17
index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Virtue Slots</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #000; overflow: hidden; }
canvas { display: block; }
</style>
</head>
<body>
<script src="https://cdn.jsdelivr.net/npm/phaser@3.60.0/dist/phaser.min.js"></script>
<script type="module" src="main.js"></script>
</body>
</html>

15
main.js Normal file
View File

@ -0,0 +1,15 @@
import BootScene from './scenes/BootScene.js';
import GameScene from './scenes/GameScene.js';
import UIScene from './scenes/UIScene.js';
new Phaser.Game({
type: Phaser.AUTO,
width: 1600,
height: 900,
backgroundColor: '#1a0a2e',
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH
},
scene: [BootScene, GameScene, UIScene]
});

80
objects/LossAnimation.js Normal file
View File

@ -0,0 +1,80 @@
export class LossAnimation {
// sinBoxCenter: { x, y } screen position of the Sin fund box
play(scene, originX, originY, sinBoxCenter, onComplete) {
// Draw devil: red triangle body + horns
const devilGfx = scene.add.graphics();
// Body — red triangle
devilGfx.fillStyle(0xcc1111, 1);
devilGfx.fillTriangle(-24, 20, 24, 20, 0, -20);
// Head — red circle
devilGfx.fillStyle(0xdd2222, 1);
devilGfx.fillCircle(0, -30, 16);
// Horns
devilGfx.fillStyle(0x880000, 1);
devilGfx.fillTriangle(-14, -42, -8, -42, -11, -56);
devilGfx.fillTriangle(8, -42, 14, -42, 11, -56);
// Eyes
devilGfx.fillStyle(0xffff00, 1);
devilGfx.fillCircle(-6, -32, 4);
devilGfx.fillCircle(6, -32, 4);
devilGfx.fillStyle(0x000000, 1);
devilGfx.fillCircle(-6, -32, 2);
devilGfx.fillCircle(6, -32, 2);
// Tail
devilGfx.lineStyle(3, 0x880000, 1);
devilGfx.beginPath();
devilGfx.moveTo(16, 10);
devilGfx.lineTo(30, 0);
devilGfx.lineTo(36, 14);
devilGfx.strokePath();
// Tail tip arrowhead
devilGfx.fillStyle(0x880000, 1);
devilGfx.fillTriangle(30, 10, 36, 14, 40, 6);
devilGfx.setPosition(originX, originY);
// Money bag label
const moneyText = scene.add.text(originX + 10, originY - 60, '💰', {
fontSize: '28px'
}).setOrigin(0.5, 0.5);
// Screen tint to dark red briefly
scene.cameras.main.flash(800, 150, 0, 0, true);
// Tween devil + money up to sin box
scene.tweens.add({
targets: [devilGfx, moneyText],
x: `+=${sinBoxCenter.x - originX}`,
y: `+=${sinBoxCenter.y - originY}`,
duration: 2800,
ease: 'Cubic.easeInOut',
onComplete: () => {
// Fade out
scene.tweens.add({
targets: [devilGfx, moneyText],
alpha: 0,
duration: 600,
onComplete: () => {
devilGfx.destroy();
moneyText.destroy();
if (onComplete) onComplete();
}
});
}
});
// Wobble
scene.tweens.add({
targets: devilGfx,
angle: { from: -8, to: 8 },
duration: 400,
repeat: 6,
yoyo: true
});
}
}

185
objects/MatchBanner.js Normal file
View File

@ -0,0 +1,185 @@
export class MatchBanner {
play(scene, label, onComplete) {
const cx = 800;
const cy = 450;
// Dark overlay behind everything
const overlay = scene.add.graphics();
overlay.fillStyle(0x000000, 0.65);
overlay.fillRect(0, 0, 1600, 900);
overlay.setAlpha(0).setDepth(10);
// Expanding burst ring from center
const burst = scene.add.graphics();
burst.lineStyle(10, 0xffd700, 1);
burst.strokeCircle(0, 0, 50);
burst.setPosition(cx, cy).setDepth(11);
scene.tweens.add({
targets: burst,
scaleX: 18, scaleY: 18,
alpha: 0,
duration: 650,
ease: 'Cubic.easeOut',
onComplete: () => burst.destroy()
});
// Main banner container — starts tiny, punches in
const container = scene.add.container(cx, cy).setScale(0.01).setDepth(12);
const PW = 1060, PH = 260, PR = 22;
// Panel background
const panel = scene.add.graphics();
panel.fillStyle(0x0d0520, 0.97);
panel.fillRoundedRect(-PW / 2, -PH / 2, PW, PH, PR);
// Sharp gold inner border
panel.lineStyle(5, 0xffd700, 1);
panel.strokeRoundedRect(-PW / 2, -PH / 2, PW, PH, PR);
// Soft outer glow border
panel.lineStyle(16, 0xffd700, 0.18);
panel.strokeRoundedRect(-PW / 2 - 9, -PH / 2 - 9, PW + 18, PH + 18, PR + 9);
// Horizontal copper divider
const divider = scene.add.graphics();
divider.lineStyle(2, 0xc8a87e, 0.55);
divider.beginPath();
divider.moveTo(-430, -8);
divider.lineTo(430, -8);
divider.strokePath();
// Corner cross ornaments
const cornerPositions = [
[-PW / 2 + 30, -PH / 2 + 24],
[ PW / 2 - 30, -PH / 2 + 24],
[-PW / 2 + 30, PH / 2 - 24],
[ PW / 2 - 30, PH / 2 - 24],
];
const cornerCrosses = cornerPositions.map(([ox, oy]) =>
scene.add.text(ox, oy, '✝', {
fontSize: '22px',
fontFamily: 'Georgia, serif',
color: '#c8a87e',
}).setOrigin(0.5, 0.5).setAlpha(0.65)
);
// "M A T C H" header
const matchTxt = scene.add.text(0, -72, 'M A T C H', {
fontSize: '36px',
fontFamily: 'Georgia, serif',
color: '#c8a87e',
stroke: '#2a0a00',
strokeThickness: 2,
}).setOrigin(0.5, 0.5);
// "3× {LABEL}" main text
const mainTxt = scene.add.text(0, 58, `3\u00D7 ${label.toUpperCase()}`, {
fontSize: '88px',
fontFamily: 'Georgia, serif',
color: '#ffd700',
stroke: '#2a0a00',
strokeThickness: 7,
shadow: { offsetX: 0, offsetY: 0, color: '#ffd700', blur: 28, fill: true },
}).setOrigin(0.5, 0.5);
container.add([panel, divider, ...cornerCrosses, matchTxt, mainTxt]);
// Sparkle particles burst outward
for (let i = 0; i < 30; i++) {
const angle = (i / 30) * Math.PI * 2 + Phaser.Math.FloatBetween(-0.15, 0.15);
const dist = Phaser.Math.Between(100, 420);
const size = Phaser.Math.Between(16, 48);
const glyph = ['✦', '★', '✝', '✦', '★'][Math.floor(Math.random() * 5)];
const sparkle = scene.add.text(
cx + Math.cos(angle) * 15,
cy + Math.sin(angle) * 15,
glyph,
{ fontSize: `${size}px`, fontFamily: 'Georgia, serif', color: '#ffd700' }
).setOrigin(0.5, 0.5).setAlpha(0).setDepth(13);
scene.tweens.add({
targets: sparkle,
x: cx + Math.cos(angle) * dist,
y: cy + Math.sin(angle) * dist,
alpha: { from: 0, to: 1 },
scale: { from: 0.1, to: 1 },
duration: Phaser.Math.Between(280, 650),
delay: Phaser.Math.Between(40, 320),
ease: 'Cubic.easeOut',
onComplete: () => {
scene.tweens.add({
targets: sparkle,
alpha: 0,
scale: 0.4,
duration: Phaser.Math.Between(350, 800),
delay: Phaser.Math.Between(150, 500),
ease: 'Cubic.easeIn',
onComplete: () => sparkle.destroy(),
});
},
});
}
// Fade in overlay
scene.tweens.add({
targets: overlay,
alpha: 1,
duration: 140,
});
// Punch in the banner with elastic overshoot
scene.tweens.add({
targets: container,
scale: 1,
duration: 520,
ease: 'Back.easeOut',
easeParams: [4],
onComplete: () => {
// Scale pulse ×3
scene.tweens.add({
targets: container,
scale: 1.035,
duration: 200,
yoyo: true,
repeat: 2,
ease: 'Sine.easeInOut',
onComplete: () => {
// Gold shimmer flicker on main text
scene.tweens.add({
targets: mainTxt,
alpha: 0.55,
duration: 90,
yoyo: true,
repeat: 4,
ease: 'Linear',
onComplete: () => {
// Hold, then zoom-fade out
scene.time.delayedCall(650, () => {
scene.tweens.add({
targets: container,
scale: 1.14,
alpha: 0,
duration: 420,
ease: 'Cubic.easeIn',
onComplete: () => container.destroy(),
});
scene.tweens.add({
targets: overlay,
alpha: 0,
duration: 420,
ease: 'Cubic.easeIn',
onComplete: () => {
overlay.destroy();
if (onComplete) onComplete();
},
});
});
},
});
},
});
},
});
}
}

122
objects/Reel.js Normal file
View File

@ -0,0 +1,122 @@
import { SYMBOLS } from './Symbol.js';
const SYMBOL_HEIGHT = 110;
const SYMBOL_WIDTH = 200;
const VISIBLE_COUNT = 3;
const REEL_HEIGHT = SYMBOL_HEIGHT * VISIBLE_COUNT;
const BUFFER = 2; // extra cells above/below visible area for smooth scroll
const POOL_SIZE = VISIBLE_COUNT + BUFFER * 2;
export class Reel {
constructor(scene, x, y) {
this.scene = scene;
this.x = x;
this.y = y;
this.w = SYMBOL_WIDTH;
this.h = REEL_HEIGHT;
// Virtual infinite strip: cycle through SYMBOLS array using modulo
this.strip = SYMBOLS; // reference, never mutated
this.scrollY = 0;
// Container with mask for clipping
this.container = scene.add.container(x, y);
const maskShape = scene.make.graphics({ add: false });
maskShape.fillRect(x, y, this.w, this.h);
this.container.setMask(maskShape.createGeometryMask());
// Small pool of Graphics + Sprite + Text triples (only as many as visible + buffer)
this.cells = [];
for (let i = 0; i < POOL_SIZE; i++) {
const gfx = scene.add.graphics();
const spr = scene.add.sprite(0, 0, 'symbols', 0).setOrigin(0.5, 0.5);
this.container.add(gfx);
this.container.add(spr);
this.cells.push({ gfx, spr });
}
this._draw();
}
// Returns the symbol showing in the center (result) slot
getCenter() {
const s = -this.scrollY;
const centerScrollY = s + SYMBOL_HEIGHT;
const idx = Math.floor(centerScrollY / SYMBOL_HEIGHT);
return this.strip[((idx % this.strip.length) + this.strip.length) % this.strip.length];
}
_draw() {
// Negate scrollY so increasing scrollY moves symbols downward (top-to-bottom direction)
const s = -this.scrollY;
const topCellIdx = Math.floor(s / SYMBOL_HEIGHT);
for (let i = 0; i < POOL_SIZE; i++) {
const virtualIdx = topCellIdx - BUFFER + i;
const symbolIdx = ((virtualIdx % this.strip.length) + this.strip.length) % this.strip.length;
const sym = this.strip[symbolIdx];
const cellY = virtualIdx * SYMBOL_HEIGHT - s;
const { gfx, spr } = this.cells[i];
gfx.clear();
gfx.fillStyle(sym.color, 1);
gfx.fillRoundedRect(2, cellY + 2, this.w - 4, SYMBOL_HEIGHT - 4, 8);
gfx.lineStyle(2, 0xffffff, 0.35);
gfx.strokeRoundedRect(2, cellY + 2, this.w - 4, SYMBOL_HEIGHT - 4, 8);
spr.setFrame(symbolIdx);
spr.setPosition(this.w / 2, cellY + SYMBOL_HEIGHT / 2);
}
}
// Spin to targetSymbol. Duration controls how long the fast phase lasts.
// onComplete fires when the reel finishes.
spin(targetSymbol, duration, onComplete) {
const scene = this.scene;
// Pre-calculate a target scrollY that:
// - lands targetSymbol in the center slot (scrollY = targetIdx * SYMBOL_HEIGHT - SYMBOL_HEIGHT)
// - is strictly greater than current scrollY by at least MIN_ADVANCE
const MIN_ADVANCE = SYMBOL_HEIGHT * 10; // enough for a satisfying visible spin
const stripHeight = this.strip.length * SYMBOL_HEIGHT;
const targetSymbolIdx = this.strip.findIndex(s => s.id === targetSymbol.id);
// With negated draw: center when -scrollY = targetSymbolIdx * SYMBOL_HEIGHT - SYMBOL_HEIGHT
// => scrollY = SYMBOL_HEIGHT - targetSymbolIdx * SYMBOL_HEIGHT
let targetScrollY = SYMBOL_HEIGHT - targetSymbolIdx * SYMBOL_HEIGHT;
// scrollY must DECREASE by at least MIN_ADVANCE (spin goes in negative direction)
while (targetScrollY >= this.scrollY - MIN_ADVANCE) {
targetScrollY -= stripHeight;
}
// Phase 1: fast linear scroll to ~2 symbols before the final landing
const fastTarget = targetScrollY + SYMBOL_HEIGHT * 2;
scene.tweens.add({
targets: this,
scrollY: fastTarget,
duration: duration,
ease: 'Linear',
onUpdate: () => this._draw(),
onComplete: () => {
// Phase 2: decelerate smoothly into the final position
scene.tweens.add({
targets: this,
scrollY: targetScrollY,
duration: 650,
ease: 'Cubic.easeOut',
onUpdate: () => this._draw(),
onComplete: () => {
this.scrollY = targetScrollY;
this._draw();
if (onComplete) onComplete();
}
});
}
});
}
getWidth() { return this.w; }
getHeight() { return this.h; }
}

83
objects/SlotMachine.js Normal file
View File

@ -0,0 +1,83 @@
import { SYMBOLS } from './Symbol.js';
import { Reel } from './Reel.js';
import { shouldWin, pickResults } from '../utils/RNG.js';
const REEL_GAP = 20;
const REEL_WIDTH = 200;
const REEL_HEIGHT = 330; // 3 * 110 (matches Reel.js SYMBOL_HEIGHT)
export class SlotMachine {
constructor(scene, centerX, centerY) {
this.scene = scene;
this.centerX = centerX;
this.centerY = centerY;
// Draw the machine frame
this.framGfx = scene.add.graphics();
this._drawFrame();
// Three reels, side by side
const totalWidth = REEL_WIDTH * 3 + REEL_GAP * 2;
const startX = centerX - totalWidth / 2;
const startY = centerY - REEL_HEIGHT / 2;
this.reels = [
new Reel(scene, startX, startY),
new Reel(scene, startX + REEL_WIDTH + REEL_GAP, startY),
new Reel(scene, startX + (REEL_WIDTH + REEL_GAP) * 2, startY),
];
// Center line indicator
const lineGfx = scene.add.graphics();
lineGfx.lineStyle(3, 0xffd700, 0.9);
lineGfx.beginPath();
lineGfx.moveTo(startX - 10, centerY);
lineGfx.lineTo(startX + totalWidth + 10, centerY);
lineGfx.strokePath();
this.lastResults = null;
}
_drawFrame() {
const g = this.framGfx;
const cx = this.centerX;
const cy = this.centerY;
const w = REEL_WIDTH * 3 + REEL_GAP * 2 + 60;
const h = REEL_HEIGHT + 60;
// Outer frame
g.fillStyle(0x2a1040, 1);
g.fillRoundedRect(cx - w / 2, cy - h / 2, w, h, 16);
g.lineStyle(4, 0xffd700, 1);
g.strokeRoundedRect(cx - w / 2, cy - h / 2, w, h, 16);
// Inner shadow
g.lineStyle(2, 0x8844aa, 0.6);
g.strokeRoundedRect(cx - w / 2 + 6, cy - h / 2 + 6, w - 12, h - 12, 12);
}
// spin(onComplete) — onComplete receives { win, symbols, payout }
spin(onComplete) {
const win = shouldWin();
const results = pickResults(SYMBOLS, win);
this.lastResults = results;
const payout = win ? results[0].payout : 0;
let doneCount = 0;
const totalReels = this.reels.length;
const stopDelays = [800, 1300, 1800]; // ms before each reel starts decelerating
this.reels.forEach((reel, i) => {
reel.spin(results[i], stopDelays[i], () => {
doneCount++;
if (doneCount === totalReels) {
onComplete({ win, symbols: results, payout });
}
});
});
}
getCenterX() { return this.centerX; }
getCenterY() { return this.centerY; }
}

17
objects/Symbol.js Normal file
View File

@ -0,0 +1,17 @@
// Symbols ordered by holiness ascending (index 0 = least holy)
export const SYMBOLS = [
{ id: 'dove', label: 'Dove', holiness: 1, payout: 100, color: 0xd0e8ff },
{ id: 'joel', label: 'Joel Osteen', holiness: 2, payout: 150, color: 0xa8d8ea },
{ id: 'bible', label: 'Holy Bible', holiness: 3, payout: 200, color: 0x7ec8e3 },
{ id: 'lamb', label: 'Lamb of God', holiness: 4, payout: 250, color: 0x88d498 },
{ id: 'crown_thorns', label: 'Crown of Thorns', holiness: 5, payout: 350, color: 0xc8a87e },
{ id: 'halo', label: 'Halo', holiness: 6, payout: 450, color: 0xf0d060 },
{ id: 'cross', label: 'The Cross', holiness: 7, payout: 600, color: 0xf0a830 },
{ id: 'jesus_cross', label: 'Jesus on Cross', holiness: 8, payout: 800, color: 0xe88020 },
{ id: 'baby_jesus', label: 'Baby Jesus', holiness: 9, payout: 1000, color: 0xff9944 },
{ id: 'holy_grail', label: 'Holy Grail', holiness: 10, payout: 1500, color: 0xffd700 },
];
export function getSymbolById(id) {
return SYMBOLS.find(s => s.id === id);
}

216
objects/WinAnimation.js Normal file
View File

@ -0,0 +1,216 @@
import { MatchBanner } from './MatchBanner.js';
export class WinAnimation {
// playerBoxCenter, lordBoxCenter: { x, y } screen positions of fund boxes
play(scene, originX, originY, playerBoxCenter, lordBoxCenter, symbol, onComplete) {
new MatchBanner().play(scene, symbol.label, () => {
// Flash the screen gold
scene.cameras.main.flash(1200, 255, 215, 0, true);
const totalCoins = 20;
const playerCoins = Math.round(totalCoins * 0.6);
const lordCoins = totalCoins - playerCoins;
let finished = 0;
// --- Split labels ---
const spawnSplitLabel = (lx, topLine, pctLine, borderColor, textColor) => {
const ly = 158;
const PW = 240, PH = 86, PR = 10;
const ctr = scene.add.container(lx, ly).setScale(0).setAlpha(0).setDepth(5);
const bg = scene.add.graphics();
// Panel fill
bg.fillStyle(0x0d0520, 0.96);
bg.fillRoundedRect(-PW / 2, -PH / 2, PW, PH, PR);
// Inner gold border
bg.lineStyle(3, borderColor, 1);
bg.strokeRoundedRect(-PW / 2, -PH / 2, PW, PH, PR);
// Outer glow
bg.lineStyle(12, borderColor, 0.2);
bg.strokeRoundedRect(-PW / 2 - 6, -PH / 2 - 6, PW + 12, PH + 12, PR + 6);
// Arrow tip pointing up toward the box above
bg.fillStyle(borderColor, 1);
bg.fillTriangle(-11, -PH / 2 + 2, 11, -PH / 2 + 2, 0, -PH / 2 - 15);
const t1 = scene.add.text(0, -20, topLine, {
fontSize: '13px',
fontFamily: 'Georgia, serif',
color: '#a89878',
letterSpacing: 2,
}).setOrigin(0.5, 0.5);
const t2 = scene.add.text(0, 18, pctLine, {
fontSize: '36px',
fontFamily: 'Georgia, serif',
fontStyle: 'bold',
color: textColor,
stroke: '#05020e',
strokeThickness: 5,
shadow: { offsetX: 0, offsetY: 0, color: textColor, blur: 16, fill: true },
}).setOrigin(0.5, 0.5);
ctr.add([bg, t1, t2]);
// Punch in
scene.tweens.add({
targets: ctr,
scale: 1,
alpha: 1,
duration: 380,
delay: 120,
ease: 'Back.easeOut',
easeParams: [3.5],
onComplete: () => {
// Vigorous shake
scene.tweens.add({
targets: ctr,
x: { from: lx - 11, to: lx + 11 },
duration: 55,
yoyo: true,
repeat: 8,
ease: 'Sine.easeInOut',
onComplete: () => {
// Pulse the percentage number
scene.tweens.add({
targets: t2,
scale: 1.3,
duration: 120,
yoyo: true,
repeat: 3,
ease: 'Sine.easeInOut',
});
// Gentle breathe on the whole panel
scene.tweens.add({
targets: ctr,
scale: 1.05,
duration: 500,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
},
});
},
});
return { ctr, stopBreath: () => scene.tweens.killTweensOf(ctr) };
};
const { ctr: playerLabel, stopBreath: stopPlayer } = spawnSplitLabel(
playerBoxCenter.x, 'YOUR WINNINGS', '60%', 0x55cc77, '#55cc77'
);
const { ctr: lordLabel, stopBreath: stopLord } = spawnSplitLabel(
lordBoxCenter.x, "THE LORD'S TITHE", '40%', 0xaa88ff, '#aa88ff'
);
const spawnCoin = (targetX, targetY) => {
const radius = 26;
const gfx = scene.add.graphics();
// Main coin body
gfx.fillStyle(0xffd700, 1);
gfx.fillCircle(0, 0, radius);
// Outer ring
gfx.lineStyle(3, 0xffa500, 1);
gfx.strokeCircle(0, 0, radius);
// Inner highlight
gfx.fillStyle(0xffe980, 0.6);
gfx.fillCircle(-6, -6, radius * 0.38);
const coinLabel = scene.add.text(0, 1, '$', {
fontSize: '22px',
fontFamily: 'Georgia, serif',
fontStyle: 'bold',
color: '#5a3000'
}).setOrigin(0.5, 0.5);
const startX = originX + Phaser.Math.Between(-70, 70);
const startY = originY + Phaser.Math.Between(-30, 30);
const container = scene.add.container(startX, startY, [gfx, coinLabel]);
container.setScale(0.1);
const delay = Phaser.Math.Between(0, 700);
// Arc peak: shoot upward between origin and target, then fall to box
const peakX = startX + (targetX - startX) * 0.35 + Phaser.Math.Between(-100, 100);
const peakY = Math.min(startY, targetY) - Phaser.Math.Between(180, 340);
const spinDir = Phaser.Math.Between(0, 1) ? 1 : -1;
// Phase 1: pop in at origin
scene.tweens.add({
targets: container,
scale: 1.5,
duration: 220,
delay,
ease: 'Back.easeOut',
onComplete: () => {
// Phase 2: arc up to peak
scene.tweens.add({
targets: container,
x: peakX,
y: peakY,
angle: spinDir * Phaser.Math.Between(120, 200),
duration: 480,
ease: 'Cubic.easeOut',
onComplete: () => {
// Phase 3: fall to target box, shrink and fade at arrival
scene.tweens.add({
targets: container,
x: targetX,
y: targetY,
scale: 0.4,
angle: `+=${spinDir * Phaser.Math.Between(200, 400)}`,
alpha: { from: 1, to: 0 },
duration: 900,
ease: 'Cubic.easeIn',
onComplete: () => {
container.destroy();
finished++;
if (finished === totalCoins) {
stopPlayer();
stopLord();
scene.tweens.add({
targets: [playerLabel, lordLabel],
alpha: 0,
scale: 0.7,
duration: 380,
ease: 'Cubic.easeIn',
onComplete: () => {
playerLabel.destroy();
lordLabel.destroy();
if (onComplete) onComplete();
}
});
}
}
});
}
});
}
});
};
// Halo glow ring expanding from center
const halo = scene.add.graphics();
halo.lineStyle(6, 0xffd700, 0.8);
halo.strokeCircle(0, 0, 10);
halo.setPosition(originX, originY);
scene.tweens.add({
targets: halo,
scaleX: 8, scaleY: 8,
alpha: 0,
duration: 1800,
ease: 'Cubic.easeOut',
onComplete: () => halo.destroy()
});
// Spawn coins to player box (60%)
for (let i = 0; i < playerCoins; i++) {
spawnCoin(playerBoxCenter.x, playerBoxCenter.y);
}
// Spawn coins to lord box (40%)
for (let i = 0; i < lordCoins; i++) {
spawnCoin(lordBoxCenter.x, lordBoxCenter.y);
}
});
}
}

14
scenes/BootScene.js Normal file
View File

@ -0,0 +1,14 @@
export default class BootScene extends Phaser.Scene {
constructor() {
super({ key: 'BootScene' });
}
preload() {
this.load.spritesheet('symbols', 'assets/symbol_sprites.png', { frameWidth: 200, frameHeight: 100 });
}
create() {
this.scene.start('GameScene');
this.scene.launch('UIScene');
}
}

160
scenes/GameScene.js Normal file
View File

@ -0,0 +1,160 @@
import { GameState } from '../state/GameState.js';
import { SlotMachine } from '../objects/SlotMachine.js';
import { WinAnimation } from '../objects/WinAnimation.js';
import { LossAnimation } from '../objects/LossAnimation.js';
export default class GameScene extends Phaser.Scene {
constructor() {
super({ key: 'GameScene' });
}
create() {
// Background gradient-ish
const bg = this.add.graphics();
bg.fillGradientStyle(0x1a0a2e, 0x1a0a2e, 0x2a0a4e, 0x2a0a4e, 1);
bg.fillRect(0, 0, 1600, 900);
// Decorative stars — floating, twinkling, pulsing
const starColors = [0xffffff, 0xffffff, 0xffffff, 0xffe8a0, 0xd0c8ff];
for (let i = 0; i < 70; i++) {
const x = Phaser.Math.Between(0, 1600);
const y = Phaser.Math.Between(120, 760);
const r = Phaser.Math.Between(1, 3);
const baseAlpha = Phaser.Math.FloatBetween(0.2, 0.75);
const color = starColors[Math.floor(Math.random() * starColors.length)];
const starGfx = this.add.graphics();
starGfx.fillStyle(color, 1);
starGfx.fillCircle(0, 0, r);
starGfx.setPosition(x, y);
starGfx.setAlpha(baseAlpha);
// Gentle drift — each star wanders a small random distance
this.tweens.add({
targets: starGfx,
x: x + Phaser.Math.Between(-18, 18),
y: y + Phaser.Math.Between(-12, 12),
duration: Phaser.Math.Between(3500, 8000),
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
delay: Phaser.Math.Between(0, 5000),
});
// Twinkle — alpha fades in and out independently
this.tweens.add({
targets: starGfx,
alpha: { from: baseAlpha * 0.15, to: baseAlpha },
duration: Phaser.Math.Between(600, 2800),
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
delay: Phaser.Math.Between(0, 3000),
});
// Scale pulse — grows and shrinks on its own rhythm
this.tweens.add({
targets: starGfx,
scale: Phaser.Math.FloatBetween(1.4, 3.2),
duration: Phaser.Math.Between(1200, 4500),
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
delay: Phaser.Math.Between(0, 4000),
});
}
// Title above the machine
this.add.text(800, 150, 'VIRTUE SLOTS', {
fontSize: '42px',
fontFamily: 'Georgia, serif',
color: '#ffd700',
stroke: '#5a3000',
strokeThickness: 4,
shadow: { offsetX: 2, offsetY: 2, color: '#000', blur: 6, fill: true }
}).setOrigin(0.5, 0.5);
this.add.text(800, 195, '✝ May Fortune Favor the Faithful ✝', {
fontSize: '18px',
fontFamily: 'Georgia, serif',
color: '#c8a87e',
alpha: 0.8
}).setOrigin(0.5, 0.5);
// Slot machine at center of play area
this.slotMachine = new SlotMachine(this, 800, 490);
this.winAnim = new WinAnimation();
this.lossAnim = new LossAnimation();
// Keyboard: Space to spin
this.input.keyboard.on('keydown-SPACE', () => this._triggerSpin());
// Listen for spin button events from UIScene via global event bus
this.game.events.on('spin', () => this._triggerSpin(), this);
}
_triggerSpin() {
if (GameState.spinning) return;
if (GameState.playerFunds < GameState.spinCost) {
this.game.events.emit('insufficient-funds');
return;
}
GameState.playerFunds -= GameState.spinCost;
GameState.spinning = true;
this.game.events.emit('funds-updated');
this.slotMachine.spin((result) => this._handleResult(result));
}
_handleResult({ win, symbols, payout }) {
if (win) {
const playerGain = Math.round(payout * 0.6);
const lordGain = payout - playerGain;
GameState.playerFunds += playerGain;
GameState.lordFunds += lordGain;
this.game.events.emit('win', { playerGain, lordGain, symbol: symbols[0] });
this.game.events.emit('funds-updated');
// Resolve UI box positions from UIScene
const uiScene = this.scene.get('UIScene');
const playerBox = uiScene ? uiScene.getPlayerBoxCenter() : { x: 267, y: 60 };
const lordBox = uiScene ? uiScene.getLordBoxCenter() : { x: 800, y: 60 };
this.winAnim.play(
this,
this.slotMachine.getCenterX(),
this.slotMachine.getCenterY(),
playerBox,
lordBox,
symbols[0],
() => {
GameState.spinning = false;
this.game.events.emit('spin-complete');
}
);
} else {
GameState.sinTotal += GameState.spinCost;
this.game.events.emit('loss', { sinAdded: GameState.spinCost });
this.game.events.emit('funds-updated');
const uiScene = this.scene.get('UIScene');
const sinBox = uiScene ? uiScene.getSinBoxCenter() : { x: 1333, y: 60 };
this.lossAnim.play(
this,
this.slotMachine.getCenterX(),
this.slotMachine.getCenterY(),
sinBox,
() => {
GameState.spinning = false;
this.game.events.emit('spin-complete');
}
);
}
}
}

205
scenes/UIScene.js Normal file
View File

@ -0,0 +1,205 @@
import { GameState } from '../state/GameState.js';
const TOP_BAR_HEIGHT = 100;
const BOTTOM_BAR_HEIGHT = 110;
const BOTTOM_BAR_Y = 900 - BOTTOM_BAR_HEIGHT;
// Box centers (x) for the three top fund displays
const BOX_WIDTH = 1600 / 3;
const PLAYER_BOX_X = BOX_WIDTH * 0 + BOX_WIDTH / 2;
const LORD_BOX_X = BOX_WIDTH * 1 + BOX_WIDTH / 2;
const SIN_BOX_X = BOX_WIDTH * 2 + BOX_WIDTH / 2;
const BOX_CENTER_Y = TOP_BAR_HEIGHT / 2;
export default class UIScene extends Phaser.Scene {
constructor() {
super({ key: 'UIScene' });
}
create() {
this._buildTopBar();
this._buildBottomBar();
this._buildSpinButton();
this._bindEvents();
this._updateFundDisplays();
}
_buildTopBar() {
const g = this.add.graphics();
// Background
g.fillStyle(0x12082a, 1);
g.fillRect(0, 0, 1600, TOP_BAR_HEIGHT);
g.lineStyle(2, 0xffd700, 0.8);
g.strokeRect(0, 0, 1600, TOP_BAR_HEIGHT);
// Dividers
g.lineStyle(1, 0xffd700, 0.3);
g.beginPath();
g.moveTo(BOX_WIDTH, 8);
g.lineTo(BOX_WIDTH, TOP_BAR_HEIGHT - 8);
g.strokePath();
g.beginPath();
g.moveTo(BOX_WIDTH * 2, 8);
g.lineTo(BOX_WIDTH * 2, TOP_BAR_HEIGHT - 8);
g.strokePath();
// Box labels
const labelStyle = {
fontSize: '13px',
fontFamily: 'Georgia, serif',
color: '#c8a87e',
alpha: 0.8
};
this.add.text(PLAYER_BOX_X, 14, 'YOUR FUNDS', labelStyle).setOrigin(0.5, 0);
this.add.text(LORD_BOX_X, 14, 'THE LORD', labelStyle).setOrigin(0.5, 0);
this.add.text(SIN_BOX_X, 14, 'SIN', labelStyle).setOrigin(0.5, 0);
// Fund value texts
const valueStyle = {
fontSize: '28px',
fontFamily: 'Georgia, serif',
color: '#ffd700',
stroke: '#2a0a4e',
strokeThickness: 3
};
this.playerText = this.add.text(PLAYER_BOX_X, 55, '$1000', valueStyle).setOrigin(0.5, 0.5);
this.lordText = this.add.text(LORD_BOX_X, 55, '$0', valueStyle).setOrigin(0.5, 0.5);
this.sinText = this.add.text(SIN_BOX_X, 55, '$0', { ...valueStyle, color: '#ff4444' }).setOrigin(0.5, 0.5);
}
_buildBottomBar() {
const g = this.add.graphics();
g.fillStyle(0x12082a, 1);
g.fillRect(0, BOTTOM_BAR_Y, 1600, BOTTOM_BAR_HEIGHT);
g.lineStyle(2, 0xffd700, 0.8);
g.strokeRect(0, BOTTOM_BAR_Y, 1600, BOTTOM_BAR_HEIGHT);
this.messageText = this.add.text(700, BOTTOM_BAR_Y + BOTTOM_BAR_HEIGHT / 2, 'Press SPIN or SPACE to begin', {
fontSize: '22px',
fontFamily: 'Georgia, serif',
color: '#e8d8b0',
align: 'center',
wordWrap: { width: 1100 }
}).setOrigin(0.5, 0.5);
// Secondary redemption message (hidden by default)
this.redeemText = this.add.text(700, BOTTOM_BAR_Y + BOTTOM_BAR_HEIGHT / 2 + 30, '', {
fontSize: '16px',
fontFamily: 'Georgia, serif',
color: '#ff9944',
align: 'center'
}).setOrigin(0.5, 0.5).setAlpha(0);
}
_buildSpinButton() {
const btnX = 1420;
const btnY = BOTTOM_BAR_Y + BOTTOM_BAR_HEIGHT / 2;
const btnW = 140;
const btnH = 60;
this.spinBtnGfx = this.add.graphics();
this._drawSpinBtn(false);
this.spinBtnHitArea = this.add.zone(btnX, btnY, btnW, btnH)
.setInteractive({ useHandCursor: true });
this.spinBtnLabel = this.add.text(btnX, btnY, 'SPIN', {
fontSize: '26px',
fontFamily: 'Georgia, serif',
color: '#1a0a2e',
fontStyle: 'bold'
}).setOrigin(0.5, 0.5);
this.spinBtnHitArea.on('pointerdown', () => {
this.game.events.emit('spin');
});
this.spinBtnHitArea.on('pointerover', () => this._drawSpinBtn(true));
this.spinBtnHitArea.on('pointerout', () => this._drawSpinBtn(false));
// Store button center for layout reference
this._btnX = btnX;
this._btnY = btnY;
this._btnW = btnW;
this._btnH = btnH;
}
_drawSpinBtn(hover) {
const btnX = 1420;
const btnY = BOTTOM_BAR_Y + BOTTOM_BAR_HEIGHT / 2;
const btnW = 140;
const btnH = 60;
this.spinBtnGfx.clear();
this.spinBtnGfx.fillStyle(hover ? 0xffe066 : 0xffd700, 1);
this.spinBtnGfx.fillRoundedRect(btnX - btnW / 2, btnY - btnH / 2, btnW, btnH, 12);
this.spinBtnGfx.lineStyle(3, hover ? 0xffa500 : 0xc8a000, 1);
this.spinBtnGfx.strokeRoundedRect(btnX - btnW / 2, btnY - btnH / 2, btnW, btnH, 12);
}
_bindEvents() {
this.game.events.on('win', ({ playerGain, lordGain, symbol }) => {
this._updateFundDisplays();
this.messageText.setText(
`✝ The Blessings of the Slots ✝\n+$${playerGain} to you | +$${lordGain} to The Lord (${symbol.label})`
);
this.messageText.setColor('#ffd700');
this.redeemText.setAlpha(0);
}, this);
this.game.events.on('loss', () => {
this._updateFundDisplays();
this.messageText.setText('Thou Hath Sinned.');
this.messageText.setColor('#ff6666');
this.redeemText.setText('Redeem Yourself!');
this.redeemText.setAlpha(1);
// Pulse the redeem text
this.tweens.add({
targets: this.redeemText,
alpha: { from: 1, to: 0.3 },
duration: 700,
yoyo: true,
repeat: 4
});
}, this);
this.game.events.on('spin-complete', () => {
// Re-enable button visually (it was never disabled, just state-guarded)
}, this);
this.game.events.on('funds-updated', () => {
this._updateFundDisplays();
}, this);
this.game.events.on('insufficient-funds', () => {
this.messageText.setText('Insufficient funds to spin! You have been consumed by Sin.');
this.messageText.setColor('#ff4444');
}, this);
}
_updateFundDisplays() {
this.playerText.setText(`$${GameState.playerFunds}`);
this.lordText.setText(`$${GameState.lordFunds}`);
this.sinText.setText(`$${GameState.sinTotal}`);
// Flash update on change
[this.playerText, this.lordText, this.sinText].forEach(t => {
this.tweens.add({
targets: t,
scaleX: { from: 1.15, to: 1 },
scaleY: { from: 1.15, to: 1 },
duration: 200,
ease: 'Bounce.easeOut'
});
});
}
// Called by GameScene to position animations toward the right box
getPlayerBoxCenter() { return { x: PLAYER_BOX_X, y: BOX_CENTER_Y }; }
getLordBoxCenter() { return { x: LORD_BOX_X, y: BOX_CENTER_Y }; }
getSinBoxCenter() { return { x: SIN_BOX_X, y: BOX_CENTER_Y }; }
}

39
software.md Normal file
View File

@ -0,0 +1,39 @@
# Build Guidelines
Create an HTML Phaser 3 video game. The game is Virtue Slots. It should play like a slot machine and win about every fifteen plays.
## Tools and Organization
- Phaser version 3 HTML game
- Use JavaScript
- Have JavaScript objects reference each other directly via IMPORT and EXPORT using ES6 standards
- Do **NOT** require a web packager.
- Create files and classes in a manner that allows future modifications and scaling at a modular level
## Basic Framework
- 1600 x 900 view
- Scale view to user's viewport.
- Use basic termporary vector graphics that can later be replaced by sprites
## Gameplay
- UI details
- Slot machine should be in the middle of the screen, at the top should be three even sides boxes for "Your Funds", "The Lord" and "Sin".
- The player starts with $1000. The Lord starts with $0. Sin starts with $0.
- The bottom of the screen should have one wide box which will be used for messaging and the "spin" button.
- It should cost $50 to Spin the slot machine.
- The slot machine should have 10 different symbols. Each symbol should be a religious christian themed symbol of some sort. Include symbols like "baby jesus", "cross", "jesus on the cross", "crown of thorns", "halo", etc.
- When the player spins the slots show the symbols spinning and then individually resting on a symbol.
- When the symbols match, the player wins.
- The amount of money the player wins should go up based on how holy the symbols that were matched are.
- Show in the messaging area how much the player has won and then animate the following:
- Split the money up 60% to the player and %40 in "Tithing" to The Lord.
- The screen should brighten, and a holy animation should slowly lift up gold and money icons up to the player and the lords funds. Create something that looks like an angel rising to heaven, but with cash.
- After the animation the message are should read "The Blessings of the Slots"
- When the symbols don't match, The player loses.
- The money that was spent on the spin should be carried up to the "Sin" box and added to "Sin" by a devil.
- The message box should read "Thou Hath Sinned." and another message should appear near the spin button that reads "Redeam Yourself!"
## Controls
- The mouse clicking the "Spin" button starts a spin, and so does the space bar.

1
start_web.bat Normal file
View File

@ -0,0 +1 @@
python -m http.server 8000

4
start_web.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/bash
# Start a simple HTTP server on port 8000
python3 -m http.server 8000

7
state/GameState.js Normal file
View File

@ -0,0 +1,7 @@
export const GameState = {
playerFunds: 1000,
lordFunds: 0,
sinTotal: 0,
spinCost: 50,
spinning: false
};

19
utils/RNG.js Normal file
View File

@ -0,0 +1,19 @@
function rand(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
export function shouldWin() {
return Math.random() < 1 / 7;
}
export function pickResults(symbols, forceWin) {
if (forceWin) {
const s = rand(symbols);
return [s, s, s];
}
let results;
do {
results = [rand(symbols), rand(symbols), rand(symbols)];
} while (results[0].id === results[1].id && results[1].id === results[2].id);
return results;
}