feat(rfid): Implement core RFID minigame system

Implemented complete RFID keycard lock system with Flipper Zero-inspired interface.

Core Components:
- RFIDDataManager: Card generation, validation, save/load, EM4100 formulas
- RFIDUIRenderer: Flipper Zero UI with unlock/clone modes
- RFIDAnimations: Progress animations and visual feedback
- RFIDMinigame: Main controller with unlock/clone modes
- CSS: Complete Flipper Zero styling (orange device, monochrome screen)

Features:
- Unlock mode: Tap physical keycards or emulate saved cards
- Clone mode: Read and save cards to RFID cloner
- EM4100 protocol with DEZ8, facility codes, checksums
- Automatic conversation return after cloning (proven pattern)
- 50-card storage limit with duplicate overwrite
- Validated 10-char hex IDs

Integration:
- Registered in MinigameFramework as 'rfid'
- Added to unlock-system.js switch statement
- Exported window.startRFIDMinigame and returnToConversationAfterRFID

Files Created:
- js/minigames/rfid/rfid-minigame.js (main controller)
- js/minigames/rfid/rfid-data.js (data management)
- js/minigames/rfid/rfid-ui.js (UI rendering)
- js/minigames/rfid/rfid-animations.js (animations)
- css/rfid-minigame.css (Flipper Zero styles)

Next: Add chat-helpers tag, interactions handler, HTML/Phaser integration
This commit is contained in:
Z. Cliffe Schreuders
2025-11-15 23:48:15 +00:00
parent a52f8da171
commit c153b44e34
7 changed files with 1531 additions and 2 deletions

376
css/rfid-minigame.css Normal file
View File

@@ -0,0 +1,376 @@
/**
* RFID Minigame CSS
* Flipper Zero-inspired RFID reader/cloner interface
*/
/* Container */
.rfid-minigame-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.9);
z-index: 1000;
}
.rfid-minigame-game-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
/* Flipper Zero Device */
.flipper-zero-frame {
width: 400px;
height: 550px;
background: #FF8200;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
padding: 20px;
display: flex;
flex-direction: column;
font-family: 'Courier New', monospace;
}
/* Header */
.flipper-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid rgba(255, 255, 255, 0.3);
}
.flipper-logo {
font-size: 14px;
font-weight: bold;
color: white;
letter-spacing: 1px;
}
.flipper-battery {
font-size: 12px;
color: white;
}
/* Screen */
.flipper-screen {
flex: 1;
background: #333;
border-radius: 10px;
padding: 15px;
color: white;
font-size: 14px;
overflow-y: auto;
box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
}
/* Breadcrumb */
.flipper-breadcrumb {
font-size: 12px;
color: #FFA500;
margin-bottom: 15px;
font-weight: bold;
}
/* Menu */
.flipper-menu {
display: flex;
flex-direction: column;
gap: 8px;
}
.flipper-menu-item {
padding: 8px 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 5px;
cursor: pointer;
transition: background 0.2s;
user-select: none;
}
.flipper-menu-item:hover {
background: rgba(255, 255, 255, 0.15);
}
/* Info Text */
.flipper-info {
color: white;
margin: 10px 0;
text-align: center;
}
.flipper-info-dim {
color: #888;
margin: 10px 0;
text-align: center;
font-size: 12px;
}
/* Card List */
.flipper-card-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 15px;
max-height: 300px;
overflow-y: auto;
}
/* Card Name */
.flipper-card-name {
font-size: 16px;
font-weight: bold;
color: #FFA500;
margin: 10px 0;
text-align: center;
}
/* Card Data */
.flipper-card-data {
background: rgba(0, 0, 0, 0.3);
padding: 15px;
border-radius: 8px;
margin: 15px 0;
font-size: 13px;
line-height: 1.8;
}
.flipper-card-data div {
margin: 5px 0;
}
/* Buttons */
.flipper-buttons {
display: flex;
gap: 10px;
margin-top: auto;
padding-top: 15px;
}
.flipper-button {
flex: 1;
padding: 12px;
background: #FF8200;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
font-family: 'Courier New', monospace;
}
.flipper-button:hover {
background: #FFA500;
transform: translateY(-2px);
}
.flipper-button-secondary {
background: #555;
}
.flipper-button-secondary:hover {
background: #777;
}
.flipper-button-back {
margin-top: auto;
padding: 10px;
color: #FFA500;
cursor: pointer;
text-align: center;
user-select: none;
}
.flipper-button-back:hover {
color: white;
}
/* NFC Waves */
.rfid-nfc-waves-container {
display: flex;
justify-content: center;
align-items: center;
margin: 30px 0;
}
.rfid-nfc-icon {
font-size: 48px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
}
.rfid-nfc-waves {
position: relative;
width: 100px;
height: 100px;
}
.rfid-nfc-wave {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 20px;
height: 20px;
border: 2px solid #FF8200;
border-radius: 50%;
animation: wave 1.5s infinite;
}
@keyframes wave {
0% {
width: 20px;
height: 20px;
opacity: 1;
}
100% {
width: 100px;
height: 100px;
opacity: 0;
}
}
/* Progress Bar */
.rfid-progress-container {
width: 100%;
height: 20px;
background: rgba(0, 0, 0, 0.3);
border-radius: 10px;
overflow: hidden;
margin: 20px 0;
}
.rfid-progress-bar {
height: 100%;
background: #FF8200;
transition: width 0.1s linear, background-color 0.3s;
border-radius: 10px;
}
/* Emulation */
.rfid-emulate-icon {
font-size: 64px;
text-align: center;
margin: 20px 0;
animation: pulse 1.5s infinite;
}
.flipper-emulating {
color: #00FF00;
text-align: center;
margin: 15px 0;
font-weight: bold;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 50%, 100% {
opacity: 1;
}
25%, 75% {
opacity: 0.5;
}
}
/* Success/Error Messages */
.flipper-success,
.flipper-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
.flipper-success-icon,
.flipper-error-icon {
font-size: 72px;
margin-bottom: 20px;
}
.flipper-success-icon {
color: #00FF00;
}
.flipper-error-icon {
color: #FF0000;
}
.flipper-success-message,
.flipper-error-message {
font-size: 18px;
font-weight: bold;
}
.flipper-success-message {
color: #00FF00;
}
.flipper-error-message {
color: #FF0000;
}
/* Scrollbar */
.flipper-screen::-webkit-scrollbar {
width: 8px;
}
.flipper-screen::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.flipper-screen::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.flipper-screen::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.flipper-card-list::-webkit-scrollbar {
width: 6px;
}
.flipper-card-list::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.flipper-card-list::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
}
/* Responsive */
@media (max-width: 500px) {
.flipper-zero-frame {
width: 90%;
height: 80vh;
min-height: 500px;
}
}

View File

@@ -15,6 +15,7 @@ export { PinMinigame, startPinMinigame } from './pin/pin-minigame.js';
export { PasswordMinigame } from './password/password-minigame.js';
export { TextFileMinigame, returnToTextFileAfterNotes } from './text-file/text-file-minigame.js';
export { TitleScreenMinigame, startTitleScreenMinigame } from './title-screen/title-screen-minigame.js';
export { RFIDMinigame, startRFIDMinigame, returnToConversationAfterRFID } from './rfid/rfid-minigame.js';
// Initialize the global minigame framework for backward compatibility
import { MinigameFramework } from './framework/minigame-manager.js';
@@ -70,8 +71,13 @@ import { PasswordMinigame } from './password/password-minigame.js';
// Import the text file minigame
import { TextFileMinigame, returnToTextFileAfterNotes } from './text-file/text-file-minigame.js';
<<<<<<< HEAD
// Import the title screen minigame
import { TitleScreenMinigame, startTitleScreenMinigame } from './title-screen/title-screen-minigame.js';
=======
// Import the RFID minigame
import { RFIDMinigame, startRFIDMinigame, returnToConversationAfterRFID } from './rfid/rfid-minigame.js';
>>>>>>> a4e2561 (feat(rfid): Implement core RFID minigame system)
// Register minigames
MinigameFramework.registerScene('lockpicking', LockpickingMinigamePhaser); // Use Phaser version as default
@@ -86,7 +92,11 @@ MinigameFramework.registerScene('person-chat', PersonChatMinigame);
MinigameFramework.registerScene('pin', PinMinigame);
MinigameFramework.registerScene('password', PasswordMinigame);
MinigameFramework.registerScene('text-file', TextFileMinigame);
<<<<<<< HEAD
MinigameFramework.registerScene('title-screen', TitleScreenMinigame);
=======
MinigameFramework.registerScene('rfid', RFIDMinigame);
>>>>>>> a4e2561 (feat(rfid): Implement core RFID minigame system)
// Make minigame functions available globally
window.startNotesMinigame = startNotesMinigame;
@@ -99,4 +109,9 @@ window.returnToConversationAfterNPCInventory = returnToConversationAfterNPCInven
window.returnToPhoneAfterNotes = returnToPhoneAfterNotes;
window.returnToTextFileAfterNotes = returnToTextFileAfterNotes;
window.startPinMinigame = startPinMinigame;
window.startTitleScreenMinigame = startTitleScreenMinigame;
<<<<<<< HEAD
window.startTitleScreenMinigame = startTitleScreenMinigame;
=======
window.startRFIDMinigame = startRFIDMinigame;
window.returnToConversationAfterRFID = returnToConversationAfterRFID;
>>>>>>> a4e2561 (feat(rfid): Implement core RFID minigame system)

View File

@@ -0,0 +1,103 @@
/**
* RFID Animations
*
* Handles animation effects for RFID minigame:
* - Card reading progress animation
* - Tap success/failure animations
* - NFC wave animations
* - Emulation success/failure animations
*
* @module rfid-animations
*/
export class RFIDAnimations {
constructor(minigame) {
this.minigame = minigame;
this.activeIntervals = [];
console.log('✨ RFIDAnimations initialized');
}
/**
* Animate card reading progress
* @param {Function} progressCallback - Called with progress (0-100)
* @returns {Promise} Resolves when reading complete
*/
animateReading(progressCallback) {
return new Promise((resolve) => {
let progress = 0;
const interval = setInterval(() => {
progress += 2;
progressCallback(progress);
if (progress >= 100) {
clearInterval(interval);
this.activeIntervals = this.activeIntervals.filter(i => i !== interval);
resolve();
}
}, 50); // 2% every 50ms = 2.5 seconds total
this.activeIntervals.push(interval);
});
}
/**
* Show tap success animation
*/
showTapSuccess() {
console.log('✅ Tap success');
// Visual feedback handled by UI layer
}
/**
* Show tap failure animation
*/
showTapFailure() {
console.log('❌ Tap failure');
// Visual feedback handled by UI layer
}
/**
* Show emulation success animation
*/
showEmulationSuccess() {
console.log('✅ Emulation success');
// Visual feedback handled by UI layer
}
/**
* Show emulation failure animation
*/
showEmulationFailure() {
console.log('❌ Emulation failure');
// Visual feedback handled by UI layer
}
/**
* Animate NFC waves
* @param {HTMLElement} container - Container element
*/
animateNFCWaves(container) {
// Create wave elements
const waves = document.createElement('div');
waves.className = 'rfid-nfc-waves';
for (let i = 0; i < 3; i++) {
const wave = document.createElement('div');
wave.className = 'rfid-nfc-wave';
wave.style.animationDelay = `${i * 0.3}s`;
waves.appendChild(wave);
}
container.appendChild(waves);
return waves;
}
/**
* Clean up all active animations
*/
cleanup() {
this.activeIntervals.forEach(interval => clearInterval(interval));
this.activeIntervals = [];
console.log('🧹 RFIDAnimations cleanup complete');
}
}

View File

@@ -0,0 +1,222 @@
/**
* RFID Data Manager
*
* Handles RFID card data management:
* - Card generation with EM4100 protocol
* - Hex ID validation
* - Card save/load to cloner device
* - Format conversions (hex, DEZ8, facility codes)
*
* @module rfid-data
*/
// Maximum number of cards that can be saved to cloner
const MAX_SAVED_CARDS = 50;
// Template names for generated cards
const CARD_NAME_TEMPLATES = [
'Security Badge',
'Employee ID',
'Access Card',
'Visitor Pass',
'Executive Key',
'Maintenance Card',
'Lab Access',
'Server Room'
];
export class RFIDDataManager {
constructor() {
console.log('🔐 RFIDDataManager initialized');
}
/**
* Generate a random RFID card with EM4100 format
* @returns {Object} Card data with hex, facility code, card number
*/
generateRandomCard() {
// Generate 10-character hex ID (5 bytes)
const hex = Array.from({ length: 10 }, () =>
Math.floor(Math.random() * 16).toString(16).toUpperCase()
).join('');
// Calculate facility code from first byte
const facility = parseInt(hex.substring(0, 2), 16);
// Calculate card number from next 2 bytes
const cardNumber = parseInt(hex.substring(2, 6), 16);
// Generate card name
const nameTemplate = CARD_NAME_TEMPLATES[Math.floor(Math.random() * CARD_NAME_TEMPLATES.length)];
const name = `${nameTemplate} #${Math.floor(Math.random() * 9000) + 1000}`;
return {
name: name,
rfid_hex: hex,
rfid_facility: facility,
rfid_card_number: cardNumber,
rfid_protocol: 'EM4100',
type: 'keycard',
key_id: `card_${hex.toLowerCase()}`
};
}
/**
* Validate hex ID format
* @param {string} hex - Hex ID to validate
* @returns {Object} {valid: boolean, error?: string}
*/
validateHex(hex) {
if (!hex || typeof hex !== 'string') {
return { valid: false, error: 'Hex ID must be a string' };
}
if (hex.length !== 10) {
return { valid: false, error: 'Hex ID must be exactly 10 characters' };
}
if (!/^[0-9A-Fa-f]{10}$/.test(hex)) {
return { valid: false, error: 'Hex ID must contain only hex characters (0-9, A-F)' };
}
return { valid: true };
}
/**
* Save card to RFID cloner device
* @param {Object} cardData - Card data to save
* @returns {Object} {success: boolean, message: string}
*/
saveCardToCloner(cardData) {
// Find rfid_cloner in inventory
const cloner = window.inventory?.items?.find(item =>
item?.scenarioData?.type === 'rfid_cloner'
);
if (!cloner) {
return { success: false, message: 'RFID cloner not found in inventory' };
}
// Validate hex ID
const validation = this.validateHex(cardData.rfid_hex);
if (!validation.valid) {
return { success: false, message: validation.error };
}
// Initialize saved_cards array if missing
if (!cloner.scenarioData.saved_cards) {
cloner.scenarioData.saved_cards = [];
}
// Check if at max capacity
if (cloner.scenarioData.saved_cards.length >= MAX_SAVED_CARDS) {
return { success: false, message: `Cloner full (max ${MAX_SAVED_CARDS} cards)` };
}
// Check for duplicate hex ID
const existingIndex = cloner.scenarioData.saved_cards.findIndex(card =>
card.rfid_hex === cardData.rfid_hex
);
if (existingIndex !== -1) {
// Overwrite existing card with updated timestamp
cloner.scenarioData.saved_cards[existingIndex] = {
...cardData,
timestamp: Date.now()
};
console.log(`📡 Overwritten duplicate card: ${cardData.name}`);
return { success: true, message: `Updated: ${cardData.name}` };
} else {
// Add new card
cloner.scenarioData.saved_cards.push({
...cardData,
timestamp: Date.now()
});
console.log(`📡 Saved new card: ${cardData.name}`);
return { success: true, message: `Saved: ${cardData.name}` };
}
}
/**
* Get all saved cards from cloner
* @returns {Array} Array of saved cards
*/
getSavedCards() {
const cloner = window.inventory?.items?.find(item =>
item?.scenarioData?.type === 'rfid_cloner'
);
if (!cloner || !cloner.scenarioData.saved_cards) {
return [];
}
return cloner.scenarioData.saved_cards;
}
/**
* Convert hex ID to facility code and card number
* EM4100 format: First byte = facility, next 2 bytes = card number
* @param {string} hex - 10-character hex ID
* @returns {Object} {facility: number, cardNumber: number}
*/
hexToFacilityCard(hex) {
const facility = parseInt(hex.substring(0, 2), 16);
const cardNumber = parseInt(hex.substring(2, 6), 16);
return { facility, cardNumber };
}
/**
* Convert facility code and card number to hex ID
* @param {number} facility - Facility code (0-255)
* @param {number} cardNumber - Card number (0-65535)
* @returns {string} 10-character hex ID
*/
facilityCardToHex(facility, cardNumber) {
// Convert to hex and pad
const facilityHex = facility.toString(16).toUpperCase().padStart(2, '0');
const cardHex = cardNumber.toString(16).toUpperCase().padStart(4, '0');
// Generate 4 random chars for remaining data
const randomHex = Array.from({ length: 4 }, () =>
Math.floor(Math.random() * 16).toString(16).toUpperCase()
).join('');
return facilityHex + cardHex + randomHex;
}
/**
* Convert hex ID to DEZ 8 format
* EM4100 DEZ 8: Last 3 bytes (6 hex chars) converted to decimal
* @param {string} hex - 10-character hex ID
* @returns {string} 8-digit decimal string with leading zeros
*/
toDEZ8(hex) {
const lastThreeBytes = hex.slice(-6);
const decimal = parseInt(lastThreeBytes, 16);
return decimal.toString().padStart(8, '0');
}
/**
* Calculate EM4100 checksum
* XOR of all bytes
* @param {string} hex - 10-character hex ID
* @returns {number} Checksum byte (0x00-0xFF)
*/
calculateChecksum(hex) {
const bytes = hex.match(/.{1,2}/g).map(b => parseInt(b, 16));
let checksum = 0;
bytes.forEach(byte => {
checksum ^= byte;
});
return checksum & 0xFF;
}
/**
* Format hex for display (add spaces every 2 chars)
* @param {string} hex - Hex string
* @returns {string} Formatted hex string
*/
formatHex(hex) {
return hex.match(/.{1,2}/g).join(' ').toUpperCase();
}
}

View File

@@ -0,0 +1,299 @@
/**
* RFID Minigame Controller
*
* Flipper Zero-inspired RFID reader/cloner minigame:
* - Unlock mode: Tap keycard or emulate saved card to unlock doors
* - Clone mode: Read and save keycard data for later emulation
*
* Modes:
* - unlock: Player needs to unlock an RFID-locked door
* - clone: Player is cloning a keycard (from conversation or inventory click)
*
* @module rfid-minigame
*/
import { MinigameScene } from '../framework/base-minigame.js';
import { RFIDUIRenderer } from './rfid-ui.js';
import { RFIDDataManager } from './rfid-data.js';
import { RFIDAnimations } from './rfid-animations.js';
export class RFIDMinigame extends MinigameScene {
constructor(container, params) {
// Set title based on mode
const title = params.mode === 'clone' ? 'Cloning Card...' : 'RFID Reader';
super(container, {
...params,
title: title,
showCancel: true,
cancelText: 'Close',
requiresKeyboardInput: false
});
// Parameters
this.params = params;
this.mode = params.mode || 'unlock'; // 'unlock' or 'clone'
this.requiredCardId = params.requiredCardId; // For unlock mode
this.availableCards = params.availableCards || []; // For unlock mode
this.hasCloner = params.hasCloner || false; // For unlock mode
this.cardToClone = params.cardToClone; // For clone mode
// Components
this.ui = null;
this.dataManager = null;
this.animations = null;
// State
this.gameResult = null;
console.log(`🔐 RFIDMinigame created in ${this.mode} mode`);
}
init() {
// Call parent init
super.init();
// Add CSS class to container
this.container.classList.add('rfid-minigame-container');
this.gameContainer.classList.add('rfid-minigame-game-container');
// Initialize components
this.dataManager = new RFIDDataManager();
this.animations = new RFIDAnimations(this);
this.ui = new RFIDUIRenderer(this);
// Create appropriate interface
if (this.mode === 'unlock') {
this.ui.createUnlockInterface();
} else if (this.mode === 'clone') {
this.ui.createCloneInterface();
}
console.log('🔐 RFIDMinigame initialized');
}
start() {
super.start();
console.log('🔐 RFIDMinigame started');
// Emit event
if (window.eventDispatcher) {
window.eventDispatcher.emit('rfid_lock_accessed', {
mode: this.mode,
timestamp: Date.now()
});
}
}
/**
* Handle card tap (unlock mode)
* @param {Object} card - Card that was tapped
*/
handleCardTap(card) {
console.log('📡 Card tapped:', card.scenarioData?.name);
const cardId = card.scenarioData?.key_id || card.key_id;
const isCorrect = cardId === this.requiredCardId;
if (isCorrect) {
this.animations.showTapSuccess();
this.ui.showSuccess('Access Granted');
setTimeout(() => {
this.complete(true);
}, 1500);
} else {
this.animations.showTapFailure();
this.ui.showError('Access Denied');
setTimeout(() => {
this.ui.showTapInterface();
}, 1500);
}
}
/**
* Handle card emulation (unlock mode)
* @param {Object} savedCard - Saved card from cloner
*/
handleEmulate(savedCard) {
console.log('📡 Emulating card:', savedCard.name);
const cardId = savedCard.key_id;
const isCorrect = cardId === this.requiredCardId;
if (isCorrect) {
this.animations.showEmulationSuccess();
this.ui.showSuccess('Access Granted');
// Emit event
if (window.eventDispatcher) {
window.eventDispatcher.emit('card_emulated', {
cardName: savedCard.name,
cardHex: savedCard.rfid_hex,
success: true,
timestamp: Date.now()
});
}
setTimeout(() => {
this.complete(true);
}, 2000);
} else {
this.animations.showEmulationFailure();
this.ui.showError('Access Denied');
// Emit event
if (window.eventDispatcher) {
window.eventDispatcher.emit('card_emulated', {
cardName: savedCard.name,
cardHex: savedCard.rfid_hex,
success: false,
timestamp: Date.now()
});
}
setTimeout(() => {
this.ui.showSavedCards();
}, 1500);
}
}
/**
* Start card reading (clone mode)
*/
startCardReading() {
console.log('📡 Starting card read...');
// Animate reading progress
this.animations.animateReading((progress) => {
this.ui.updateReadingProgress(progress);
}).then(() => {
// Reading complete - show card data
console.log('📡 Card read complete');
this.ui.showCardDataScreen(this.cardToClone);
});
}
/**
* Handle save card (clone mode)
* @param {Object} cardData - Card data to save
*/
handleSaveCard(cardData) {
console.log('💾 Saving card:', cardData.name);
const result = this.dataManager.saveCardToCloner(cardData);
if (result.success) {
this.ui.showSuccess(result.message);
// Emit event
if (window.eventDispatcher) {
window.eventDispatcher.emit('card_cloned', {
cardName: cardData.name,
cardHex: cardData.rfid_hex,
timestamp: Date.now()
});
}
this.gameResult = {
success: true,
cardSaved: true,
cardData: cardData
};
setTimeout(() => {
this.complete(true);
}, 1500);
} else {
this.ui.showError(result.message);
setTimeout(() => {
this.ui.showCardDataScreen(cardData);
}, 1500);
}
}
complete(success) {
// Check if we need to return to conversation
if (window.pendingConversationReturn && window.returnToConversationAfterRFID) {
console.log('Returning to conversation after RFID minigame');
setTimeout(() => {
window.returnToConversationAfterRFID();
}, 100);
}
// Call parent complete
super.complete(success, this.gameResult);
}
cleanup() {
// Cleanup animations
if (this.animations) {
this.animations.cleanup();
}
// Call parent cleanup
super.cleanup();
console.log('🧹 RFIDMinigame cleanup complete');
}
}
/**
* Start RFID minigame
* @param {Object} lockable - The locked object (for unlock mode)
* @param {string} type - 'door' or 'item' (for unlock mode)
* @param {Object} params - Minigame parameters
*/
export function startRFIDMinigame(lockable, type, params) {
console.log('🔐 Starting RFID minigame', { mode: params.mode, params });
// Initialize framework if needed
if (!window.MinigameFramework.mainGameScene && window.game) {
window.MinigameFramework.init(window.game.scene.scenes[0]);
}
// Start minigame
window.MinigameFramework.startMinigame('rfid', lockable, params);
}
/**
* Return to conversation after RFID minigame
* Follows exact pattern from container minigame
* @see /js/minigames/container/container-minigame.js:720-754
*/
export function returnToConversationAfterRFID() {
console.log('Returning to conversation after RFID minigame');
// Check if there's a pending conversation return
if (window.pendingConversationReturn) {
const conversationState = window.pendingConversationReturn;
// Clear the pending return state
window.pendingConversationReturn = null;
console.log('Restoring conversation:', conversationState);
// Restart the appropriate conversation minigame
if (window.MinigameFramework) {
// Small delay to ensure RFID minigame is fully closed
setTimeout(() => {
if (conversationState.type === 'person-chat') {
// Restart person-chat minigame
window.MinigameFramework.startMinigame('person-chat', null, {
npcId: conversationState.npcId,
fromTag: true // Flag to indicate resuming from tag action
});
} else if (conversationState.type === 'phone-chat') {
// Restart phone-chat minigame
window.MinigameFramework.startMinigame('phone-chat', null, {
npcId: conversationState.npcId,
fromTag: true
});
}
}, 50);
}
} else {
console.log('No pending conversation return found');
}
}

View File

@@ -0,0 +1,462 @@
/**
* RFID UI Renderer
*
* Renders Flipper Zero-style RFID interface:
* - Main menu (Read / Saved)
* - Tap interface (unlock mode)
* - Saved cards list
* - Emulation screen
* - Card reading screen (clone mode)
* - Card data display
*
* @module rfid-ui
*/
export class RFIDUIRenderer {
constructor(minigame) {
this.minigame = minigame;
this.container = minigame.gameContainer;
this.dataManager = minigame.dataManager;
console.log('🎨 RFIDUIRenderer initialized');
}
/**
* Create unlock mode interface
*/
createUnlockInterface() {
this.clear();
// Create Flipper Zero frame
const flipper = this.createFlipperFrame();
// Show main menu
this.showMainMenu('unlock');
this.container.appendChild(flipper);
}
/**
* Create clone mode interface
*/
createCloneInterface() {
this.clear();
// Create Flipper Zero frame
const flipper = this.createFlipperFrame();
// Auto-start reading if card provided
if (this.minigame.params.cardToClone) {
this.showReadingScreen();
} else {
this.showMainMenu('clone');
}
this.container.appendChild(flipper);
}
/**
* Create Flipper Zero device frame
* @returns {HTMLElement} Flipper frame element
*/
createFlipperFrame() {
const frame = document.createElement('div');
frame.className = 'flipper-zero-frame';
// Header with logo and battery
const header = document.createElement('div');
header.className = 'flipper-header';
const logo = document.createElement('div');
logo.className = 'flipper-logo';
logo.textContent = 'FLIPPER ZERO';
const battery = document.createElement('div');
battery.className = 'flipper-battery';
battery.textContent = '⚡ 100%';
header.appendChild(logo);
header.appendChild(battery);
// Screen container
const screen = document.createElement('div');
screen.className = 'flipper-screen';
screen.id = 'rfid-screen';
frame.appendChild(header);
frame.appendChild(screen);
return frame;
}
/**
* Get screen element
* @returns {HTMLElement} Screen element
*/
getScreen() {
return document.getElementById('rfid-screen');
}
/**
* Show main menu
* @param {string} mode - 'unlock' or 'clone'
*/
showMainMenu(mode) {
const screen = this.getScreen();
screen.innerHTML = '';
// Breadcrumb
const breadcrumb = document.createElement('div');
breadcrumb.className = 'flipper-breadcrumb';
breadcrumb.textContent = 'RFID';
screen.appendChild(breadcrumb);
// Menu items
const menu = document.createElement('div');
menu.className = 'flipper-menu';
if (mode === 'unlock') {
// Read option (tap cards)
const readOption = document.createElement('div');
readOption.className = 'flipper-menu-item';
readOption.textContent = '> Read';
readOption.addEventListener('click', () => this.showTapInterface());
menu.appendChild(readOption);
// Saved option (emulate)
const savedOption = document.createElement('div');
savedOption.className = 'flipper-menu-item';
savedOption.textContent = ' Saved';
savedOption.addEventListener('click', () => this.showSavedCards());
menu.appendChild(savedOption);
} else {
// Clone mode - just show "Reading..." message
const info = document.createElement('div');
info.className = 'flipper-info';
info.textContent = 'Place card...';
menu.appendChild(info);
}
screen.appendChild(menu);
}
/**
* Show tap interface for unlock mode
*/
showTapInterface() {
const screen = this.getScreen();
screen.innerHTML = '';
// Breadcrumb
const breadcrumb = document.createElement('div');
breadcrumb.className = 'flipper-breadcrumb';
breadcrumb.textContent = 'RFID > Read';
screen.appendChild(breadcrumb);
// NFC waves animation
const waves = document.createElement('div');
waves.className = 'rfid-nfc-waves-container';
waves.innerHTML = '<div class="rfid-nfc-icon">📡</div>';
screen.appendChild(waves);
// Instruction
const instruction = document.createElement('div');
instruction.className = 'flipper-info';
instruction.textContent = 'Place card near reader...';
screen.appendChild(instruction);
// List available keycards
const cardList = document.createElement('div');
cardList.className = 'flipper-card-list';
const availableCards = this.minigame.params.availableCards || [];
if (availableCards.length === 0) {
const noCards = document.createElement('div');
noCards.className = 'flipper-info-dim';
noCards.textContent = 'No keycards in inventory';
cardList.appendChild(noCards);
} else {
availableCards.forEach(card => {
const cardItem = document.createElement('div');
cardItem.className = 'flipper-menu-item';
cardItem.textContent = `> ${card.scenarioData?.name || 'Keycard'}`;
cardItem.addEventListener('click', () => {
this.minigame.handleCardTap(card);
});
cardList.appendChild(cardItem);
});
}
screen.appendChild(cardList);
// Back button
const back = document.createElement('div');
back.className = 'flipper-button-back';
back.textContent = '← Back';
back.addEventListener('click', () => this.showMainMenu('unlock'));
screen.appendChild(back);
}
/**
* Show saved cards list
*/
showSavedCards() {
const screen = this.getScreen();
screen.innerHTML = '';
// Breadcrumb
const breadcrumb = document.createElement('div');
breadcrumb.className = 'flipper-breadcrumb';
breadcrumb.textContent = 'RFID > Saved';
screen.appendChild(breadcrumb);
// Get saved cards
const savedCards = this.dataManager.getSavedCards();
if (savedCards.length === 0) {
const noCards = document.createElement('div');
noCards.className = 'flipper-info';
noCards.textContent = 'No saved cards';
screen.appendChild(noCards);
} else {
// Card list
const cardList = document.createElement('div');
cardList.className = 'flipper-card-list';
savedCards.forEach(card => {
const cardItem = document.createElement('div');
cardItem.className = 'flipper-menu-item';
cardItem.textContent = `> ${card.name}`;
cardItem.addEventListener('click', () => this.showEmulationScreen(card));
cardList.appendChild(cardItem);
});
screen.appendChild(cardList);
}
// Back button
const back = document.createElement('div');
back.className = 'flipper-button-back';
back.textContent = '← Back';
back.addEventListener('click', () => this.showMainMenu('unlock'));
screen.appendChild(back);
}
/**
* Show emulation screen
* @param {Object} card - Card to emulate
*/
showEmulationScreen(card) {
const screen = this.getScreen();
screen.innerHTML = '';
// Breadcrumb
const breadcrumb = document.createElement('div');
breadcrumb.className = 'flipper-breadcrumb';
breadcrumb.textContent = 'RFID > Saved > Emulate';
screen.appendChild(breadcrumb);
// Emulation icon
const icon = document.createElement('div');
icon.className = 'rfid-emulate-icon';
icon.textContent = '📡';
screen.appendChild(icon);
// Protocol
const protocol = document.createElement('div');
protocol.className = 'flipper-info';
protocol.textContent = 'EM-Micro EM4100';
screen.appendChild(protocol);
// Card name
const name = document.createElement('div');
name.className = 'flipper-card-name';
name.textContent = card.name;
screen.appendChild(name);
// Card data
const { facility, cardNumber } = this.dataManager.hexToFacilityCard(card.rfid_hex);
const data = document.createElement('div');
data.className = 'flipper-card-data';
data.innerHTML = `
<div>HEX: ${this.dataManager.formatHex(card.rfid_hex)}</div>
<div>Facility: ${facility}</div>
<div>Card: ${cardNumber}</div>
`;
screen.appendChild(data);
// Emulating message
const emulating = document.createElement('div');
emulating.className = 'flipper-emulating';
emulating.textContent = 'Emulating...';
screen.appendChild(emulating);
// Trigger emulation after showing screen
setTimeout(() => {
this.minigame.handleEmulate(card);
}, 500);
}
/**
* Show card reading screen (clone mode)
*/
showReadingScreen() {
const screen = this.getScreen();
screen.innerHTML = '';
// Breadcrumb
const breadcrumb = document.createElement('div');
breadcrumb.className = 'flipper-breadcrumb';
breadcrumb.textContent = 'RFID > Read';
screen.appendChild(breadcrumb);
// Status
const status = document.createElement('div');
status.className = 'flipper-info';
status.textContent = 'Reading 1/2';
screen.appendChild(status);
// Modulation
const modulation = document.createElement('div');
modulation.className = 'flipper-info-dim';
modulation.textContent = '> ASK PSK';
screen.appendChild(modulation);
// Instruction
const instruction = document.createElement('div');
instruction.className = 'flipper-info';
instruction.textContent = "Don't move card...";
screen.appendChild(instruction);
// Progress bar
const progressContainer = document.createElement('div');
progressContainer.className = 'rfid-progress-container';
const progressBar = document.createElement('div');
progressBar.className = 'rfid-progress-bar';
progressBar.id = 'rfid-progress-bar';
progressContainer.appendChild(progressBar);
screen.appendChild(progressContainer);
// Start reading animation
this.minigame.startCardReading();
}
/**
* Update reading progress
* @param {number} progress - Progress percentage (0-100)
*/
updateReadingProgress(progress) {
const progressBar = document.getElementById('rfid-progress-bar');
if (progressBar) {
progressBar.style.width = `${progress}%`;
// Change color based on progress
if (progress < 50) {
progressBar.style.backgroundColor = '#FF8200';
} else if (progress < 100) {
progressBar.style.backgroundColor = '#FFA500';
} else {
progressBar.style.backgroundColor = '#00FF00';
}
}
}
/**
* Show card data screen after reading
* @param {Object} cardData - Read card data
*/
showCardDataScreen(cardData) {
const screen = this.getScreen();
screen.innerHTML = '';
// Breadcrumb
const breadcrumb = document.createElement('div');
breadcrumb.className = 'flipper-breadcrumb';
breadcrumb.textContent = 'RFID > Read';
screen.appendChild(breadcrumb);
// Protocol
const protocol = document.createElement('div');
protocol.className = 'flipper-info';
protocol.textContent = 'EM-Micro EM4100';
screen.appendChild(protocol);
// Card data
const { facility, cardNumber } = this.dataManager.hexToFacilityCard(cardData.rfid_hex);
const checksum = this.dataManager.calculateChecksum(cardData.rfid_hex);
const dez8 = this.dataManager.toDEZ8(cardData.rfid_hex);
const data = document.createElement('div');
data.className = 'flipper-card-data';
data.innerHTML = `
<div>HEX: ${this.dataManager.formatHex(cardData.rfid_hex)}</div>
<div>Facility: ${facility}</div>
<div>Card: ${cardNumber}</div>
<div>Checksum: 0x${checksum.toString(16).toUpperCase().padStart(2, '0')}</div>
<div>DEZ 8: ${dez8}</div>
`;
screen.appendChild(data);
// Buttons
const buttons = document.createElement('div');
buttons.className = 'flipper-buttons';
const saveBtn = document.createElement('button');
saveBtn.className = 'flipper-button';
saveBtn.textContent = 'Save';
saveBtn.addEventListener('click', () => this.minigame.handleSaveCard(cardData));
const cancelBtn = document.createElement('button');
cancelBtn.className = 'flipper-button flipper-button-secondary';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', () => this.minigame.complete(false));
buttons.appendChild(saveBtn);
buttons.appendChild(cancelBtn);
screen.appendChild(buttons);
}
/**
* Show success message
* @param {string} message - Success message
*/
showSuccess(message) {
const screen = this.getScreen();
screen.innerHTML = '';
const success = document.createElement('div');
success.className = 'flipper-success';
success.innerHTML = `
<div class="flipper-success-icon">✓</div>
<div class="flipper-success-message">${message}</div>
`;
screen.appendChild(success);
}
/**
* Show error message
* @param {string} message - Error message
*/
showError(message) {
const screen = this.getScreen();
screen.innerHTML = '';
const error = document.createElement('div');
error.className = 'flipper-error';
error.innerHTML = `
<div class="flipper-error-icon">✗</div>
<div class="flipper-error-message">${message}</div>
`;
screen.appendChild(error);
}
/**
* Clear screen
*/
clear() {
this.container.innerHTML = '';
}
}

View File

@@ -301,7 +301,59 @@ export function handleUnlock(lockable, type) {
'error', 'Device Not Found', 5000);
}
break;
case 'rfid':
console.log('RFID LOCK UNLOCK ATTEMPT');
const requiredCardId = lockRequirements.requires;
console.log('RFID CARD REQUIRED', requiredCardId);
// Check for keycards in inventory
const keycards = window.inventory.items.filter(item =>
item && item.scenarioData &&
item.scenarioData.type === 'keycard'
);
// Check for RFID cloner with saved cards
const cloner = window.inventory.items.find(item =>
item && item.scenarioData &&
item.scenarioData.type === 'rfid_cloner'
);
const hasCloner = !!cloner;
const savedCards = cloner?.scenarioData?.saved_cards || [];
// Combine available cards
const availableCards = [...keycards];
console.log('RFID CHECK', {
requiredCardId,
hasCloner,
keycardsCount: keycards.length,
savedCardsCount: savedCards.length
});
if (keycards.length > 0 || savedCards.length > 0) {
// Start RFID minigame in unlock mode
window.startRFIDMinigame(lockable, type, {
mode: 'unlock',
requiredCardId: requiredCardId,
availableCards: availableCards,
hasCloner: hasCloner,
onComplete: (success) => {
if (success) {
setTimeout(() => {
unlockTarget(lockable, type, lockable.layer);
window.gameAlert('RFID lock unlocked!', 'success', 'Access Granted', 3000);
}, 100);
}
}
});
} else {
console.log('NO RFID CARDS OR CLONER AVAILABLE');
window.gameAlert('Requires RFID keycard', 'error', 'Access Denied', 4000);
}
break;
default:
window.gameAlert(`This ${type} requires ${lockRequirements.lockType} to unlock.`, 'info', 'Locked', 4000);
break;