golfgame/docs/v3/V3_09_KNOCK_EARLY_DRAMA.md
adlee-was-taken 9fc6b83bba v3.0.0: V3 features, server refactoring, and documentation overhaul
- Extract WebSocket handlers from main.py into handlers.py
- Add V3 feature docs (dealer rotation, dealing animation, round end reveal,
  column pair celebration, final turn urgency, opponent thinking, score tallying,
  card hover/selection, knock early drama, column pair indicator, swap animation
  improvements, draw source distinction, card value tooltips, active rules context,
  discard pile history, realistic card sounds)
- Add V3 refactoring docs (ai.py, main.py/game.py, misc improvements)
- Add installation guide with Docker, systemd, and nginx setup
- Add helper scripts (install.sh, dev-server.sh, docker-build.sh)
- Add animation flow diagrams documentation
- Add test files for handlers, rooms, and V3 features
- Add e2e test specs for V3 features
- Update README with complete project structure and current tech stack
- Update CLAUDE.md with full architecture tree and server layer descriptions
- Update .env.example to reflect PostgreSQL (remove SQLite references)
- Update .gitignore to exclude virtualenv files, .claude/, and .db files
- Remove tracked virtualenv files (bin/, lib64, pyvenv.cfg)
- Remove obsolete game_log.py (SQLite) and games.db

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:03:45 -05:00

452 lines
11 KiB
Markdown

# V3-09: Knock Early Drama
## Overview
The "Knock Early" house rule lets players flip all remaining face-down cards (if 2 or fewer) to immediately trigger final turn. This is a high-risk, high-reward move that deserves dramatic presentation.
**Dependencies:** None
**Dependents:** None
---
## Goals
1. Make knock early feel dramatic and consequential
2. Show confirmation dialog (optional - it's risky!)
3. Dramatic animation when knock happens
4. Clear feedback showing the decision
5. Other players see "Player X knocked early!"
---
## Current State
From `app.js`:
```javascript
knockEarly() {
if (!this.gameState || !this.gameState.knock_early) return;
this.send({ type: 'knock_early' });
this.hideToast();
}
```
The knock early button exists but there's no special visual treatment.
---
## Design
### Knock Early Flow
```
1. Player clicks "Knock Early" button
2. Confirmation prompt: "Reveal your hidden cards and go out?"
3. If confirmed:
a. Dramatic sound effect
b. Player's hidden cards flip rapidly in sequence
c. "KNOCK!" banner appears
d. Final turn badge triggers
4. Other players see announcement
```
### Visual Elements
- **Confirmation dialog** - "Are you sure?" with preview
- **Rapid flip animation** - Cards flip faster than normal
- **"KNOCK!" banner** - Large dramatic announcement
- **Screen shake** (subtle) - Impact feeling
---
## Implementation
### Confirmation Dialog
```javascript
knockEarly() {
if (!this.gameState || !this.gameState.knock_early) return;
// Count hidden cards
const myData = this.getMyPlayerData();
const hiddenCards = myData.cards.filter(c => !c.face_up);
if (hiddenCards.length === 0 || hiddenCards.length > 2) {
return; // Can't knock
}
// Show confirmation
this.showKnockConfirmation(hiddenCards.length, () => {
this.executeKnockEarly();
});
}
showKnockConfirmation(hiddenCount, onConfirm) {
// Create modal
const modal = document.createElement('div');
modal.className = 'knock-confirm-modal';
modal.innerHTML = `
<div class="knock-confirm-content">
<div class="knock-confirm-icon">⚡</div>
<h3>Knock Early?</h3>
<p>You'll reveal ${hiddenCount} hidden card${hiddenCount > 1 ? 's' : ''} and trigger final turn.</p>
<p class="knock-warning">This cannot be undone!</p>
<div class="knock-confirm-buttons">
<button class="btn btn-secondary knock-cancel">Cancel</button>
<button class="btn btn-primary knock-confirm">Knock!</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Bind events
modal.querySelector('.knock-cancel').addEventListener('click', () => {
this.playSound('click');
modal.remove();
});
modal.querySelector('.knock-confirm').addEventListener('click', () => {
this.playSound('click');
modal.remove();
onConfirm();
});
// Click outside to cancel
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
}
async executeKnockEarly() {
// Play dramatic sound
this.playSound('knock');
// Get positions of hidden cards
const myData = this.getMyPlayerData();
const hiddenPositions = myData.cards
.map((card, i) => ({ card, position: i }))
.filter(({ card }) => !card.face_up)
.map(({ position }) => position);
// Start rapid flip animation
await this.animateKnockFlips(hiddenPositions);
// Show KNOCK banner
this.showKnockBanner();
// Send to server
this.send({ type: 'knock_early' });
this.hideToast();
}
async animateKnockFlips(positions) {
// Rapid sequential flips
const flipDelay = 150; // Faster than normal
for (const position of positions) {
const myData = this.getMyPlayerData();
const card = myData.cards[position];
this.fireLocalFlipAnimation(position, card);
this.playSound('flip');
await this.delay(flipDelay);
}
// Wait for last flip
await this.delay(300);
}
showKnockBanner() {
const banner = document.createElement('div');
banner.className = 'knock-banner';
banner.innerHTML = '<span>KNOCK!</span>';
document.body.appendChild(banner);
// Screen shake effect
document.body.classList.add('screen-shake');
// Remove after animation
setTimeout(() => {
banner.classList.add('fading');
document.body.classList.remove('screen-shake');
}, 800);
setTimeout(() => {
banner.remove();
}, 1100);
}
```
### CSS
```css
/* Knock confirmation modal */
.knock-confirm-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 300;
animation: modal-fade-in 0.2s ease-out;
}
@keyframes modal-fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
.knock-confirm-content {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
padding: 30px;
border-radius: 15px;
text-align: center;
max-width: 320px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
animation: modal-scale-in 0.2s ease-out;
}
@keyframes modal-scale-in {
0% { transform: scale(0.9); }
100% { transform: scale(1); }
}
.knock-confirm-icon {
font-size: 3em;
margin-bottom: 10px;
}
.knock-confirm-content h3 {
margin: 0 0 15px;
color: #f4a460;
}
.knock-confirm-content p {
margin: 0 0 10px;
color: rgba(255, 255, 255, 0.8);
}
.knock-warning {
color: #e74c3c !important;
font-size: 0.9em;
}
.knock-confirm-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.knock-confirm-buttons .btn {
flex: 1;
}
/* KNOCK banner */
.knock-banner {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
z-index: 400;
pointer-events: none;
animation: knock-banner-in 0.3s ease-out forwards;
}
.knock-banner span {
display: block;
font-size: 4em;
font-weight: 900;
color: #f4a460;
text-shadow:
0 0 20px rgba(244, 164, 96, 0.8),
0 0 40px rgba(244, 164, 96, 0.4),
2px 2px 0 #1a1a2e;
letter-spacing: 0.2em;
}
@keyframes knock-banner-in {
0% {
transform: translate(-50%, -50%) scale(0);
opacity: 0;
}
50% {
transform: translate(-50%, -50%) scale(1.2);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
}
.knock-banner.fading {
animation: knock-banner-out 0.3s ease-out forwards;
}
@keyframes knock-banner-out {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(0.8);
opacity: 0;
}
}
/* Screen shake effect */
@keyframes screen-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-3px); }
40% { transform: translateX(3px); }
60% { transform: translateX(-2px); }
80% { transform: translateX(2px); }
}
body.screen-shake {
animation: screen-shake 0.3s ease-out;
}
/* Enhanced knock early button */
#knock-early-btn {
background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%);
animation: knock-btn-pulse 2s ease-in-out infinite;
}
@keyframes knock-btn-pulse {
0%, 100% {
box-shadow: 0 2px 10px rgba(214, 48, 49, 0.3);
}
50% {
box-shadow: 0 2px 20px rgba(214, 48, 49, 0.5);
}
}
```
### Knock Sound
```javascript
// In playSound() method
} else if (type === 'knock') {
// Dramatic "knock" sound - low thud
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'sine';
osc.frequency.setValueAtTime(80, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(40, ctx.currentTime + 0.15);
gain.gain.setValueAtTime(0.4, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.2);
// Secondary impact
setTimeout(() => {
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.connect(gain2);
gain2.connect(ctx.destination);
osc2.type = 'sine';
osc2.frequency.setValueAtTime(60, ctx.currentTime);
gain2.gain.setValueAtTime(0.2, ctx.currentTime);
gain2.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1);
osc2.start(ctx.currentTime);
osc2.stop(ctx.currentTime + 0.1);
}, 100);
}
```
### Opponent Sees Knock
When another player knocks, show announcement:
```javascript
// In state change detection or game_state handler
if (newState.phase === 'final_turn' && oldState?.phase !== 'final_turn') {
const knocker = newState.players.find(p => p.id === newState.finisher_id);
if (knocker && knocker.id !== this.playerId) {
// Someone else knocked
this.showOpponentKnockAnnouncement(knocker.name);
}
}
showOpponentKnockAnnouncement(playerName) {
this.playSound('alert');
const banner = document.createElement('div');
banner.className = 'opponent-knock-banner';
banner.innerHTML = `<span>${playerName} knocked!</span>`;
document.body.appendChild(banner);
setTimeout(() => {
banner.classList.add('fading');
}, 1500);
setTimeout(() => {
banner.remove();
}, 1800);
}
```
---
## Test Scenarios
1. **Knock with 1 hidden card** - Single flip, then knock banner
2. **Knock with 2 hidden cards** - Rapid double flip
3. **Cancel confirmation** - Modal closes, no action
4. **Opponent knocks** - See announcement
5. **Can't knock (3+ hidden)** - Button disabled
6. **Can't knock (all face-up)** - Button disabled
---
## Acceptance Criteria
- [ ] Confirmation dialog appears before knock
- [ ] Dialog shows number of cards to reveal
- [ ] Cancel button works
- [ ] Knock triggers rapid flip animation
- [ ] "KNOCK!" banner appears with fanfare
- [ ] Subtle screen shake effect
- [ ] Other players see announcement
- [ ] Final turn triggers after knock
- [ ] Sound effects enhance the drama
---
## Implementation Order
1. Add knock sound to `playSound()`
2. Implement `showKnockConfirmation()` method
3. Implement `executeKnockEarly()` method
4. Implement `animateKnockFlips()` method
5. Implement `showKnockBanner()` method
6. Add CSS for modal and banner
7. Implement opponent knock announcement
8. Add screen shake effect
9. Test all scenarios
10. Tune timing for maximum drama
---
## Notes for Agent
- **CSS vs anime.js**: CSS is fine for modal/button animations (UI chrome). Screen shake can use anime.js for precision.
- The confirmation prevents accidental knocks (it's irreversible)
- Keep animation fast - drama without delay
- The screen shake should be subtle (accessibility)
- Consider: skip confirmation option for experienced players?
- Make sure knock works even if animations fail