feat: Implement KeyCutCalculator utility for consistent key cut depth calculations across the game

refactor: Update key-lock system and minigame starters to utilize KeyCutCalculator for cut depth generation
chore: Normalize keyPins in scenario data to align with new cut depth calculations
This commit is contained in:
Z. Cliffe Schreuders
2025-11-24 13:41:35 +00:00
parent 96cec569f7
commit 5ecfa5db27
6 changed files with 92 additions and 272 deletions

View File

@@ -506,9 +506,6 @@ export async function create() {
window.gameState.globalVariables = {};
}
// Normalize keyPins in all rooms and objects from 0-100 scale to 25-65 scale
normalizeScenarioKeyPins(gameScenario);
// Debug: log what we loaded
console.log('🎮 Loaded gameScenario with rooms:', Object.keys(gameScenario?.rooms || {}));
if (gameScenario?.rooms?.office1) {
@@ -969,121 +966,7 @@ function findNPCAtPosition(worldX, worldY) {
return closestNPC;
}
/**
* Normalize keyPins from 0-100 scale to 25-65 scale in entire scenario
*
* KEYPIN TERMINOLOGY AND RELATIONSHIPS:
* =====================================
*
* Lock Pin Anatomy:
* - Each lock chamber contains TWO pins stacked vertically:
* 1. DRIVER PIN (top, spring-loaded, blocks rotation)
* 2. KEY PIN (bottom, rests on key when inserted)
*
* - The SHEAR LINE is the boundary between the plug and housing
* - Lock opens when ALL key pins' tops align exactly at the shear line
*
* KeyPins Property:
* - "keyPins" represents the LENGTH of the key pins (bottom pins) in the lock
* - In scenarios: Defined on a 0-100 authoring scale for ease of design
* - In game: Converted to 25-65 pixel range for actual rendering
* - Example: [100, 0, 100, 0] → [65, 25, 65, 25] pixels
*
* KeyPins on LOCKS vs KEYS:
* - On a LOCK/DOOR: The actual pin lengths in that lock
* - On a KEY: The lock configuration this key is designed to open
* (i.e., a key with keyPins: [65, 25, 65, 25] opens locks with those pin lengths)
*
* Relationship: KeyPins → Cuts:
* - CUTS are the depths of notches carved into the key blade
* - Formula: cutDepth = keyPinLength - gapFromKeyBladeTopToShearLine
* - Where gap = 20 pixels (175 - 155)
* - Example: keyPin 65 → cut 45, keyPin 25 → cut 5
* - When key is inserted, pin rests on cut surface, lifting to shear line
*
* Why Normalization is Critical:
* - Scenarios use 0-100 for easy authoring (percentages)
* - Game needs 25-65 pixel range for proper physics/rendering
* - Normalization must happen EXACTLY ONCE to avoid double-conversion
* - This function runs during scenario load before any sprites are created
*/
function normalizeScenarioKeyPins(scenario) {
// Helper function to convert a single keyPins value
function convertKeyPin(value) {
// Convert from 0-100 scale to 25-65 scale
// Formula: 25 + (value / 100) * 40
// This gives us a 40-pixel range centered in the lock chamber
return Math.round(25 + (value / 100) * 40);
}
// IMPORTANT: Normalize keyPins in ALL places they appear in the scenario!
// KeyPins must be normalized exactly once at scenario load time, BEFORE any items are dropped or used.
// If keyPins appear in multiple places (startItemsInInventory, room objects, NPC itemsHeld, etc.),
// they ALL need normalization or keys/locks won't match when used.
// For example:
// - A key in an NPC's itemsHeld must be normalized the same way as a room object key
// - The door's room-level keyPins must be normalized the same way
// - Otherwise when the NPC drops the key, it won't open the door!
// Normalize keyPins in startItemsInInventory (for starting keys)
if (scenario.startItemsInInventory && Array.isArray(scenario.startItemsInInventory)) {
scenario.startItemsInInventory.forEach((item, index) => {
if (item.keyPins && Array.isArray(item.keyPins)) {
item.keyPins = item.keyPins.map(convertKeyPin);
console.log(`🔄 Normalized startItem keyPins [${index}] (${item.type} "${item.name}"):`, item.keyPins);
}
});
}
// Iterate through all rooms
Object.entries(scenario.rooms).forEach(([roomId, roomData]) => {
if (!roomData) return;
// Convert room-level keyPins (for door locks)
if (roomData.keyPins && Array.isArray(roomData.keyPins)) {
roomData.keyPins = roomData.keyPins.map(convertKeyPin);
console.log(`🔄 Normalized room keyPins for ${roomId}:`, roomData.keyPins);
}
// Normalize NPC itemsHeld keyPins (items NPCs start with/carry)
// CRITICAL: This ensures keys dropped by NPCs match the door's keyPins
if (roomData.npcs && Array.isArray(roomData.npcs)) {
roomData.npcs.forEach((npc, npcIndex) => {
if (npc.itemsHeld && Array.isArray(npc.itemsHeld)) {
npc.itemsHeld.forEach((item, itemIndex) => {
if (item.keyPins && Array.isArray(item.keyPins)) {
item.keyPins = item.keyPins.map(convertKeyPin);
console.log(`🔄 Normalized NPC itemsHeld keyPins for ${roomId}.npcs[${npcIndex}].itemsHeld[${itemIndex}] (${item.type}):`, item.keyPins);
}
});
}
});
}
// Convert keyPins for all objects in the room
if (roomData.objects && Array.isArray(roomData.objects)) {
roomData.objects.forEach((obj, index) => {
if (obj.keyPins && Array.isArray(obj.keyPins)) {
obj.keyPins = obj.keyPins.map(convertKeyPin);
console.log(`🔄 Normalized object keyPins for ${roomId}[${index}] (${obj.type}):`, obj.keyPins);
}
// Also check contents of objects (for keys in briefcases, etc.)
if (obj.contents && Array.isArray(obj.contents)) {
obj.contents.forEach((content, contentIndex) => {
if (content.keyPins && Array.isArray(content.keyPins)) {
content.keyPins = content.keyPins.map(convertKeyPin);
console.log(`🔄 Normalized content keyPins for ${roomId}[${index}].contents[${contentIndex}] (${content.type}):`, content.keyPins);
}
});
}
});
}
});
console.log('✓ All keyPins normalized to 25-65 range');
}
// Hide a room
function hideRoom(roomId) {

View File

@@ -12,6 +12,9 @@
* - this.parent.lockState (lock state object)
* etc.
*/
import KeyCutCalculator from '../../utils/key-cut-calculator.js';
export class KeyDataGenerator {
constructor(parent) {
@@ -20,42 +23,12 @@ export class KeyDataGenerator {
generateKeyDataFromPins() {
// Generate key cuts based on actual pin heights
// Calculate cut depths so that when key is inserted, pins align at shear line
const cuts = [];
const shearLineY = -45; // Shear line position (in pin container coordinates)
const keyBladeHeight = 110; // Key blade height (from keyConfig)
const pinContainerY = 200; // Pin container Y position (world coordinates)
// Uses KeyCutCalculator utility for consistent calculation across all code paths
const keyPinLengths = this.parent.pins
.slice(0, this.parent.pinCount)
.map(pin => pin.keyPinLength);
for (let i = 0; i < this.parent.pinCount; i++) {
const pin = this.parent.pins[i];
const keyPinLength = pin.keyPinLength;
// Simple key cut calculation:
// The cut depth should be the key pin length minus the gap from key blade top to shear line
// Key blade is centered in keyway: keywayStartY + keywayHeight/2 = 170 + 60 = 230
// Key blade top is: 230 - keyBladeHeight/2 = 230 - 55 = 175
// Shear line is at y=155 in world coordinates (200 - 45)
const keyBladeTop_world = 175; // 170 + 60 - 55 (keywayStartY + keywayHeight/2 - keyBladeHeight/2)
const shearLine_world = 155; // 200 - 45 (pin container Y - shear line Y)
const gapFromKeyBladeTopToShearLine = keyBladeTop_world - shearLine_world; // 175 - 155 = 20
// Cut depth = key pin length - gap from key blade top to shear line
const cutDepth_needed = keyPinLength - gapFromKeyBladeTopToShearLine;
// Clamp to valid range (0 to key blade height)
const clampedCutDepth = Math.max(0, Math.min(keyBladeHeight, cutDepth_needed));
console.log(`=== KEY CUT ${i} GENERATION ===`);
console.log(` Pin properties: keyPinLength=${keyPinLength}, driverPinLength=${pin.driverPinLength}`);
console.log(` Key blade top: ${keyBladeTop_world}, shear line: ${shearLine_world}`);
console.log(` Gap from key blade top to shear line: ${gapFromKeyBladeTopToShearLine}`);
console.log(` Cut calculation: cutDepth_needed=${cutDepth_needed} (${keyPinLength} - ${gapFromKeyBladeTopToShearLine})`);
console.log(` Final cut: cutDepth=${clampedCutDepth}px (max ${keyBladeHeight}px)`);
console.log(`=====================================`);
cuts.push(clampedCutDepth);
}
const cuts = KeyCutCalculator.calculateCutDepthsRounded(keyPinLengths);
this.parent.keyData = { cuts: cuts };
console.log('Generated key data from pins:', this.parent.keyData);

View File

@@ -7,6 +7,8 @@
* This ensures consistent lock configurations and key cuts throughout the game.
*/
import KeyCutCalculator from '../utils/key-cut-calculator.js';
// Global key-lock mapping system
// This ensures each key matches exactly one lock in the game
window.keyLockMappings = window.keyLockMappings || {};
@@ -252,35 +254,13 @@ export function generateKeyCutsForLock(key, lockable, overrideKeyPins = null) {
} else if (lockable?.keyPins || lockable?.key_pins) {
keyPinsToUse = lockable.keyPins || lockable.key_pins;
console.log(`✓ Using keyPins from lockable object:`, keyPinsToUse);
}
};
}
// If we have keyPins from the scenario, use them directly
if (keyPinsToUse && Array.isArray(keyPinsToUse)) {
console.log(`Generating cuts for key "${key.scenarioData.name}" using scenario keyPins:`, keyPinsToUse);
const cuts = [];
for (let i = 0; i < keyPinsToUse.length; i++) {
const keyPinLength = keyPinsToUse[i];
// Calculate cut depth with relationship to key pin length
// Based on the lockpicking minigame formula:
// Cut depth = key pin length - gap from key blade top to shear line
const keyBladeTop_world = 175; // Key blade top position
const shearLine_world = 155; // Shear line position
const gapFromKeyBladeTopToShearLine = keyBladeTop_world - shearLine_world; // 20
// Calculate the required cut depth
const cutDepth_needed = keyPinLength - gapFromKeyBladeTopToShearLine;
// Clamp to valid range (0 to 110, which is key blade height)
const clampedCutDepth = Math.max(0, Math.min(110, cutDepth_needed));
cuts.push(Math.round(clampedCutDepth));
console.log(`Pin ${i}: keyPinLength=${keyPinLength}, cutDepth=${clampedCutDepth} (gap=${gapFromKeyBladeTopToShearLine})`);
}
const cuts = KeyCutCalculator.calculateCutDepthsRounded(keyPinsToUse);
console.log(`Generated cuts for key ${keyId} using scenario keyPins:`, cuts);
return cuts;
}
@@ -298,23 +278,7 @@ export function generateKeyCutsForLock(key, lockable, overrideKeyPins = null) {
for (let i = 0; i < lockConfig.pinCount; i++) {
const keyPinLength = pinHeights[i] || 30; // Use predefined pin height
// Calculate cut depth with relationship to key pin length
// Based on the lockpicking minigame formula:
// Cut depth = key pin length - gap from key blade top to shear line
const keyBladeTop_world = 175; // Key blade top position
const shearLine_world = 155; // Shear line position
const gapFromKeyBladeTopToShearLine = keyBladeTop_world - shearLine_world; // 20
// Calculate the required cut depth
const cutDepth_needed = keyPinLength - gapFromKeyBladeTopToShearLine;
// Clamp to valid range (0 to 110, which is key blade height)
const clampedCutDepth = Math.max(0, Math.min(110, cutDepth_needed));
cuts.push(Math.round(clampedCutDepth));
console.log(`Pin ${i}: keyPinLength=${keyPinLength}, cutDepth=${clampedCutDepth} (gap=${gapFromKeyBladeTopToShearLine})`);
cuts.push(KeyCutCalculator.calculateCutDepth(keyPinLength));
}
console.log(`Generated cuts for key ${keyId} (assigned to ${mapping.lockId}):`, cuts);
@@ -356,20 +320,8 @@ export function generateKeyCutsForLock(key, lockable, overrideKeyPins = null) {
for (let i = 0; i < lockConfig.pinCount; i++) {
const keyPinLength = pinHeights[i] || (25 + Math.random() * 37.5); // Default if missing
// Calculate cut depth with INVERSE relationship to key pin length
// Based on the lockpicking minigame formula:
// Cut depth = key pin length - gap from key blade top to shear line
const keyBladeTop_world = 175; // Key blade top position
const shearLine_world = 155; // Shear line position
const gapFromKeyBladeTopToShearLine = keyBladeTop_world - shearLine_world; // 20
// Calculate the required cut depth
const cutDepth_needed = keyPinLength - gapFromKeyBladeTopToShearLine;
// Clamp to valid range (0 to 110, which is key blade height)
const clampedCutDepth = Math.max(0, Math.min(110, cutDepth_needed));
cuts.push(Math.round(clampedCutDepth));
// Calculate cut depth using utility
cuts.push(KeyCutCalculator.calculateCutDepth(keyPinLength));
}
console.log(`Generated cuts for key ${key.scenarioData.key_id}:`, cuts);

View File

@@ -7,6 +7,7 @@
*/
import { generateKeyCutsForLock, doesKeyMatchLock, PREDEFINED_LOCK_CONFIGS } from './key-lock-system.js';
import KeyCutCalculator from '../utils/key-cut-calculator.js';
export function startLockpickingMinigame(lockable, scene, difficulty = 'medium', callback, keyPins = null) {
console.log('🎮 startLockpickingMinigame called with:', {
@@ -125,20 +126,13 @@ export function startLockpickingMinigame(lockable, scene, difficulty = 'medium',
// KEYPIN TO CUT CONVERSION (for available keys):
// If no cuts but keyPins exists, generate cuts from the lock configuration.
// keyPins on a key = the lock configuration this key opens (already normalized to 25-65)
// keyPins on a key = the lock configuration this key opens (in pixel units: 25-65)
if (!cuts && (key.scenarioData.keyPins || key.keyPins)) {
const lockKeyPins = key.scenarioData.keyPins || key.keyPins;
console.log(`Generating cuts from lock keyPins for available key "${key.scenarioData.name}":`, lockKeyPins);
// Convert lock pin lengths to key cut depths
cuts = lockKeyPins.map(keyPinLength => {
const keyBladeTop_world = 175; // Key blade top when inserted
const shearLine_world = 155; // Shear line position
const gapFromKeyBladeTopToShearLine = 20; // Distance between them
const cutDepth_needed = keyPinLength - gapFromKeyBladeTopToShearLine; // Cut depth formula
const clampedCutDepth = Math.max(0, Math.min(110, cutDepth_needed)); // Clamp to blade height
return Math.round(clampedCutDepth);
});
// Convert lock pin lengths to key cut depths using utility
cuts = KeyCutCalculator.calculateCutDepthsRounded(lockKeyPins);
console.log(`Generated cuts for key "${key.scenarioData.name}":`, cuts);
}
@@ -160,19 +154,11 @@ export function startLockpickingMinigame(lockable, scene, difficulty = 'medium',
let cuts = keyData.cuts;
// KEYPIN TO CUT CONVERSION (for key ring keys):
// Same conversion as above - keyPins cuts using the formula
// Convert keyPins to cuts using KeyCutCalculator utility
if (!cuts && keyData.keyPins) {
const lockKeyPins = keyData.keyPins;
console.log(`Generating cuts from lock keyPins for key ring key "${keyData.name}":`, lockKeyPins);
cuts = lockKeyPins.map(keyPinLength => {
const keyBladeTop_world = 175; // Key blade top when inserted
const shearLine_world = 155; // Shear line position
const gapFromKeyBladeTopToShearLine = 20; // Distance between them
const cutDepth_needed = keyPinLength - gapFromKeyBladeTopToShearLine; // Cut depth formula
const clampedCutDepth = Math.max(0, Math.min(110, cutDepth_needed)); // Clamp to blade height
return Math.round(clampedCutDepth);
});
cuts = KeyCutCalculator.calculateCutDepthsRounded(lockKeyPins);
console.log(`Generated cuts for key ring key "${keyData.name}":`, cuts);
}
@@ -268,34 +254,18 @@ export function startKeySelectionMinigame(lockable, type, playerKeys, requiredKe
// If no cuts but keyPins exists, we need to generate cuts from the lock configuration.
//
// Remember: keyPins on a KEY represent the LOCK configuration this key is designed to open.
// The keyPins values have already been normalized from 0-100 to 25-65 pixel range.
// The keyPins values are in pixel units (25-65 range).
//
// We convert keyPins (lock pin lengths) to cuts (key blade notch depths) using the formula:
// cutDepth = keyPinLength - gapFromKeyBladeTopToShearLine
// cutDepth = keyPinLength + 8px (for the curved bottom of the pin)
//
// This ensures when the key is inserted, each pin rests on its corresponding cut,
// lifting the pin so its top aligns exactly with the shear line.
// This ensures when the key is inserted, each pin rests on its corresponding cut.
if (!cuts && (key.scenarioData.keyPins || key.keyPins)) {
const lockKeyPins = key.scenarioData.keyPins || key.keyPins;
console.log(`Generating cuts from lock keyPins for key "${key.scenarioData.name}":`, lockKeyPins);
// Generate cuts that match this lock configuration
cuts = lockKeyPins.map(keyPinLength => {
// Key blade geometry (in world coordinates):
const keyBladeTop_world = 175; // Top surface of key blade when inserted
const shearLine_world = 155; // Where lock plug meets housing
const gapFromKeyBladeTopToShearLine = keyBladeTop_world - shearLine_world; // 20 pixels
// Formula: cutDepth = keyPinLength - gap
// Example: If keyPin is 65 pixels long, cut must be 45 pixels deep (65 - 20)
// This ensures the pin's bottom rests on the cut, lifting its top to the shear line
const cutDepth_needed = keyPinLength - gapFromKeyBladeTopToShearLine;
// Clamp to valid range (0 to 110, which is key blade height)
// This prevents negative cuts or cuts deeper than the blade itself
const clampedCutDepth = Math.max(0, Math.min(110, cutDepth_needed));
return Math.round(clampedCutDepth);
});
// Generate cuts that match this lock configuration using utility
cuts = KeyCutCalculator.calculateCutDepthsRounded(lockKeyPins);
console.log(`Generated cuts for key "${key.scenarioData.name}":`, cuts);
}
@@ -447,25 +417,12 @@ export function startKeySelectionMinigame(lockable, type, playerKeys, requiredKe
let cuts = key.scenarioData.cuts;
// KEYPIN TO CUT CONVERSION (deferred update):
// Same as above - convert normalized keyPins (25-65) to cuts for visualization
// Convert keyPins to cuts for visualization using utility
// This ensures each key displays its actual unique cut pattern
if (!cuts && (key.scenarioData.keyPins || key.keyPins)) {
const lockKeyPins = key.scenarioData.keyPins || key.keyPins;
console.log(`Generating cuts from lock keyPins for key "${key.scenarioData.name}":`, lockKeyPins);
// Generate cuts that match this lock configuration
cuts = lockKeyPins.map(keyPinLength => {
const keyBladeTop_world = 175; // Key blade top position
const shearLine_world = 155; // Shear line position
const gapFromKeyBladeTopToShearLine = keyBladeTop_world - shearLine_world; // 20
// Calculate the required cut depth
const cutDepth_needed = keyPinLength - gapFromKeyBladeTopToShearLine;
// Clamp to valid range (0 to 110, which is key blade height)
const clampedCutDepth = Math.max(0, Math.min(110, cutDepth_needed));
return Math.round(clampedCutDepth);
});
cuts = KeyCutCalculator.calculateCutDepthsRounded(lockKeyPins);
console.log(`Generated cuts for key "${key.scenarioData.name}":`, cuts);
}

View File

@@ -0,0 +1,55 @@
/**
* KeyCutCalculator
*
* Utility for calculating key cut depths based on key pin lengths
* Uses the geometric formula: cutDepth = keyPinLength - gapFromKeyBladeTopToShearLine
*
* The gap of 20px accounts for:
* - Key blade top at Y=175 (keyway center 230 - blade height/2 55)
* - Shear line at Y=155 (pin container 200 - shear line local -45)
* - Gap = 175 - 155 = 20px
*/
export class KeyCutCalculator {
// The geometric gap between key blade top and shear line
static GAP_FROM_KEY_BLADE_TOP_TO_SHEAR_LINE = 20;
// Maximum key blade height (constraint)
static MAX_KEY_BLADE_HEIGHT = 110;
// Minimum cut depth
static MIN_CUT_DEPTH = 0;
/**
* Calculate cut depth for a single key pin
* @param {number} keyPinLength - Height of the key pin in pixels
* @returns {number} Cut depth in pixels, clamped to valid range
*/
static calculateCutDepth(keyPinLength) {
const cutDepth = keyPinLength - this.GAP_FROM_KEY_BLADE_TOP_TO_SHEAR_LINE;
return Math.max(
this.MIN_CUT_DEPTH,
Math.min(this.MAX_KEY_BLADE_HEIGHT, cutDepth)
);
}
/**
* Calculate cut depths for an array of key pin lengths
* @param {number[]} keyPinLengths - Array of key pin heights
* @returns {number[]} Array of cut depths
*/
static calculateCutDepths(keyPinLengths) {
return keyPinLengths.map(keyPinLength => this.calculateCutDepth(keyPinLength));
}
/**
* Calculate and round cut depths for an array of key pin lengths
* @param {number[]} keyPinLengths - Array of key pin heights
* @returns {number[]} Array of rounded cut depths
*/
static calculateCutDepthsRounded(keyPinLengths) {
return this.calculateCutDepths(keyPinLengths).map(depth => Math.round(depth));
}
}
export default KeyCutCalculator;

View File

@@ -189,7 +189,7 @@
"name": "Office Key",
"takeable": true,
"key_id": "office1_key",
"keyPins": [100, 0, 100, 0],
"keyPins": [65, 25, 65, 25],
"observations": "A key to access the office areas"
},
{
@@ -224,7 +224,7 @@
"locked": true,
"lockType": "key",
"requires": "office1_key",
"keyPins": [100, 0, 100, 0],
"keyPins": [65, 25, 65, 25],
"difficulty": "easy",
"door_sign": "4A Hot Desks",
@@ -292,7 +292,7 @@
"name": "CEO Office Key",
"takeable": true,
"key_id": "ceo_office_key",
"keyPins": [0, 50, 100, 150],
"keyPins": [25, 45, 65, 85],
"observations": "A spare key to the CEO's office, carelessly left behind"
}
]
@@ -332,7 +332,7 @@
"locked": true,
"lockType": "key",
"requires": "ceo_office_key",
"keyPins": [0, 50, 100, 150],
"keyPins": [25, 45, 65, 85],
"difficulty": "easy",
"objects": [
{
@@ -355,7 +355,7 @@
"locked": true,
"lockType": "key",
"requires": "briefcase_key",
"keyPins": [50, 25, 0, 75],
"keyPins": [45, 35, 25, 55],
"difficulty": "medium",
"observations": "An expensive leather briefcase with a sturdy lock",
"contents": [
@@ -372,7 +372,7 @@
"name": "Safe Key",
"takeable": true,
"key_id": "safe_key",
"keyPins": [68, 10, 48, 30],
"keyPins": [52, 29, 44, 37],
"observations": "A heavy-duty safe key hidden behind server equipment"
}
]
@@ -405,7 +405,7 @@
"locked": true,
"lockType": "key",
"requires": "safe_key",
"keyPins": [68, 10, 48, 30],
"keyPins": [52, 29, 44, 37],
"difficulty": "hard",
"observations": "A well-hidden wall safe behind a painting",
"contents": [
@@ -441,7 +441,7 @@
"name": "Briefcase Key",
"takeable": true,
"key_id": "briefcase_key",
"keyPins": [50, 25, 0, 75],
"keyPins": [45, 35, 25, 55],
"observations": "A small key labeled 'Personal - Do Not Copy'"
}
]