460 lines
20 KiB
Rust
460 lines
20 KiB
Rust
use crate::keyboard;
|
|
use crate::keyboard::KeyAction;
|
|
use crate::sound;
|
|
use crate::vga_buffer::{self, Color, ColorCode};
|
|
|
|
struct Choice {
|
|
text: &'static str,
|
|
score_modifier: usize,
|
|
next_node: &'static str,
|
|
}
|
|
|
|
struct NodeDef {
|
|
id: &'static str,
|
|
scene_title: Option<&'static str>,
|
|
text: &'static str,
|
|
choices: &'static [Choice],
|
|
is_terminal: bool,
|
|
}
|
|
|
|
fn get_node(id: &str) -> &'static NodeDef {
|
|
let mut i = 0;
|
|
while i < ALL_NODES.len() {
|
|
if ALL_NODES[i].id == id {
|
|
return &ALL_NODES[i];
|
|
}
|
|
i += 1;
|
|
}
|
|
&ALL_NODES[0]
|
|
}
|
|
|
|
const ALL_NODES: &[NodeDef] = &[
|
|
NodeDef {
|
|
id: "START",
|
|
scene_title: Some("Scene 1: The Old Woman's Attic"),
|
|
text: "You stand in a dimly lit attic filled with contraband books. The old woman refuses to leave her collection. Captain Beatty holds the igniter and looks at you: 'Do your duty, Montag.'",
|
|
choices: &[
|
|
Choice { text: "Burn the books and drag her out. Maintain order.", score_modifier: 10, next_node: "SCENE_2A_PARLOR" },
|
|
Choice { text: "Hesitate and secretly slip a book into your coat sleeve.", score_modifier: 25, next_node: "SCENE_2B_SUBWAY" },
|
|
Choice { text: "Drop your igniter, openly defend her, and refuse to burn history.", score_modifier: 15, next_node: "SCENE_2C_IMMEDIATE_REBELLION" },
|
|
],
|
|
is_terminal: false,
|
|
},
|
|
NodeDef {
|
|
id: "SCENE_2A_PARLOR",
|
|
scene_title: Some("Scene 2A: The Parlor Walls"),
|
|
text: "You are at home. Mildred and her friends are completely brainwashed, watching the 'White Clowns' scream on the 3-wall parlor TVs.",
|
|
choices: &[
|
|
Choice { text: "Sit down and zone out with them. Forget the attic entirely.", score_modifier: 10, next_node: "SCENE_3A_PROMOTION" },
|
|
Choice { text: "Pull out a hidden book and force them to listen to poetry.", score_modifier: 30, next_node: "SCENE_3B_TRAP" },
|
|
Choice { text: "Aggressively smash the parlor wall television screens to wake them up.", score_modifier: 15, next_node: "SCENE_3D_PARLOR_DENUNCIATION" },
|
|
],
|
|
is_terminal: false,
|
|
},
|
|
NodeDef {
|
|
id: "SCENE_2B_SUBWAY",
|
|
scene_title: Some("Scene 2B: The Sieve and the Sand"),
|
|
text: "You ride the subway with a stolen Bible. A massive audio advertisement for 'Denham's Dentifrice' blasts over the speakers, trying to numb your brain.",
|
|
choices: &[
|
|
Choice { text: "Give in to the noise. Close the book. Conformity is easier.", score_modifier: 10, next_node: "SCENE_3A_PROMOTION" },
|
|
Choice { text: "Fight the noise. Keep reading intently to memorize the text.", score_modifier: 30, next_node: "SCENE_3C_FABER_HIDE" },
|
|
Choice { text: "Stand up and scream back at the speakers, shouting scripture at the passengers.", score_modifier: 15, next_node: "SCENE_3E_SUBWAY_ARREST" },
|
|
],
|
|
is_terminal: false,
|
|
},
|
|
NodeDef {
|
|
id: "SCENE_2C_IMMEDIATE_REBELLION",
|
|
scene_title: Some("Scene 2C: The Attic Standoff"),
|
|
text: "Beatty looks at you with dangerous contempt. The old woman strikes a kitchen match herself. The attic begins to catch fire with you still inside.",
|
|
choices: &[
|
|
Choice { text: "Panic, betray your ideals, and sprint out the door with Beatty.", score_modifier: 5, next_node: "SCENE_2A_PARLOR" },
|
|
Choice { text: "Try to wrestle the match from her to save her life manually.", score_modifier: 20, next_node: "ENDING_DIE_IN_FLAMES" },
|
|
Choice { text: "Stand still in solidarity, choosing to burn alongside her and her books.", score_modifier: 25, next_node: "ENDING_MARTYR_FIRE" },
|
|
],
|
|
is_terminal: false,
|
|
},
|
|
NodeDef {
|
|
id: "SCENE_3A_PROMOTION",
|
|
scene_title: Some("Route Alpha: The Tyrant's Path"),
|
|
text: "Beatty praises your loyalty at the station and offers you a permanent promotion to Fire Captain. You have successfully killed your own curiosity.",
|
|
choices: &[
|
|
Choice { text: "Accept the promotion. Put on the Captain's helmet.", score_modifier: 10, next_node: "ENDING_TOTALITARIAN" },
|
|
Choice { text: "Vomit from guilt, reject the promotion, and run out of the station.", score_modifier: 20, next_node: "SCENE_3B_TRAP" },
|
|
Choice { text: "Accept the badge, but secretly vow to use your new power to sabotage firehouses from within.", score_modifier: 25, next_node: "ENDING_INSIDER_SABOTAGE" },
|
|
],
|
|
is_terminal: false,
|
|
},
|
|
NodeDef {
|
|
id: "SCENE_3B_TRAP",
|
|
scene_title: Some("Route Beta: The Martyr's Fire"),
|
|
text: "Mildred reports your treason. Beatty drives the Salamander to your own house and forces you to burn it. In a flash of panic, you turn the flamethrower on Beatty.",
|
|
choices: &[
|
|
Choice { text: "Flee blindly into the dark alleyways as the Mechanical Hound is deployed.", score_modifier: 20, next_node: "SCENE_4A_THE_HUNT" },
|
|
Choice { text: "Surrender to the arriving police firemen, dropping your weapon.", score_modifier: 5, next_node: "ENDING_ELIMINATED" },
|
|
Choice { text: "Stand your ground and attempt to fight off the approaching Mechanical Hound with your bare hands.", score_modifier: 15, next_node: "ENDING_HOUNDED_DEATH" },
|
|
],
|
|
is_terminal: false,
|
|
},
|
|
NodeDef {
|
|
id: "SCENE_3C_FABER_HIDE",
|
|
scene_title: Some("Route Gamma: The Intellectual Underground"),
|
|
text: "You safely reach Professor Faber's house. He hands you a small two-way communication radio 'green bullet' and tells you to run toward the river to find the Book People.",
|
|
choices: &[
|
|
Choice { text: "Dive directly into the river currents and float away from the mechanical city.", score_modifier: 30, next_node: "SCENE_4B_THE_RIVER" },
|
|
Choice { text: "Insist on staying to defend Faber from the tracker units coming for him.", score_modifier: 15, next_node: "ENDING_DEFENDING_FABER" },
|
|
Choice { text: "Lose your nerve, hide in Faber's closet, and pray they pass by.", score_modifier: 5, next_node: "ENDING_HOUNDED_DEATH" },
|
|
],
|
|
is_terminal: false,
|
|
},
|
|
NodeDef {
|
|
id: "SCENE_3D_PARLOR_DENUNCIATION",
|
|
scene_title: Some("Scene 3D: The Broken Screen"),
|
|
text: "Mildred's friends scream in absolute terror at your violent outbursts. The neighborhood alarms start sounding. You hear sirens approaching fast.",
|
|
choices: &[
|
|
Choice { text: "Sprint out the back window before the authorities encircle the block.", score_modifier: 15, next_node: "SCENE_3B_TRAP" },
|
|
Choice { text: "Sit down on the floor amid the broken glass and passively await your fate.", score_modifier: 5, next_node: "ENDING_ELIMINATED" },
|
|
Choice { text: "Force Mildred at gunpoint to flee into the night with you.", score_modifier: 10, next_node: "ENDING_TRAITOR_EXECUTION" },
|
|
],
|
|
is_terminal: false,
|
|
},
|
|
NodeDef {
|
|
id: "SCENE_3E_SUBWAY_ARREST",
|
|
scene_title: Some("Scene 3E: The Platform Corner"),
|
|
text: "Underground transit guards intercept you at the next terminal stop. You are pinned aggressively against the cold concrete tile wall. The commuters ignore you.",
|
|
choices: &[
|
|
Choice { text: "Quietly swallow an experimental chemical capsule hidden in your lapel to protect Faber's identity.", score_modifier: 25, next_node: "ENDING_SILENT_SUICIDE" },
|
|
Choice { text: "Beg for mercy and trade the location of Faber's residence for an official pardon.", score_modifier: 0, next_node: "ENDING_BETRAYER" },
|
|
Choice { text: "Break away violently and jump directly onto the electrified transit rails below.", score_modifier: 10, next_node: "ENDING_RAIL_DEATH" },
|
|
],
|
|
is_terminal: false,
|
|
},
|
|
NodeDef {
|
|
id: "SCENE_4A_THE_HUNT",
|
|
scene_title: Some("Scene 4A: The Live-Streamed Pursuit"),
|
|
text: "Helicopters illuminate the alleyways above. The entire city watches your chase live on television. You reach the edge of the dark riverbanks, your feet splashing in mud.",
|
|
choices: &[
|
|
Choice { text: "Plunge directly into the cold river to mask your scent trail from the Hound.", score_modifier: 30, next_node: "SCENE_4B_THE_RIVER" },
|
|
Choice { text: "Turn around to face the camera drones, delivering an impassioned final speech about freedom.", score_modifier: 25, next_node: "ENDING_TELEVISED_EXECUTION" },
|
|
Choice { text: "Try to cross the open highway on foot to reach the distant countryside factories.", score_modifier: 10, next_node: "ENDING_HOUNDED_DEATH" },
|
|
],
|
|
is_terminal: false,
|
|
},
|
|
NodeDef {
|
|
id: "SCENE_4B_THE_RIVER",
|
|
scene_title: Some("Scene 4B: The Gathering at the Tracks"),
|
|
text: "You emerge from the river waters far outside city limits. You discover Granger and a group of traveling, displaced academics cooking over an open fire. They invite you to pick a text to memorize.",
|
|
choices: &[
|
|
Choice { text: "Choose to preserve the Book of Ecclesiastes to give future society wisdom.", score_modifier: 35, next_node: "ENDING_RENAISSANCE" },
|
|
Choice { text: "Choose to preserve the Book of Revelation to remind them of the destructive past.", score_modifier: 35, next_node: "ENDING_RENAISSANCE" },
|
|
Choice { text: "Refuse to memorize anything, claiming humanity is an unfixable error.", score_modifier: 10, next_node: "ENDING_CYNIC_DESERTION" },
|
|
],
|
|
is_terminal: false,
|
|
},
|
|
// ── Endings ──────────────────────────────────────────────────────────
|
|
NodeDef {
|
|
id: "ENDING_TOTALITARIAN",
|
|
scene_title: None,
|
|
text: "You allowed the Dystopian Setting to conquer your individuality. Montag is dead inside, a permanent agent of the status quo.",
|
|
choices: &[],
|
|
is_terminal: true,
|
|
},
|
|
NodeDef {
|
|
id: "ENDING_RENAISSANCE",
|
|
scene_title: None,
|
|
text: "Jet bombers flatten the city. You survive downriver with the scholars as a living library, marching back to rebuild a literate civilization.",
|
|
choices: &[],
|
|
is_terminal: true,
|
|
},
|
|
NodeDef {
|
|
id: "ENDING_MARTYR_FIRE",
|
|
scene_title: None,
|
|
text: "You burn alive in the attic with the old woman. Your death becomes an unerasable legend whispered among hidden rebels.",
|
|
choices: &[],
|
|
is_terminal: true,
|
|
},
|
|
NodeDef {
|
|
id: "ENDING_DIE_IN_FLAMES",
|
|
scene_title: None,
|
|
text: "You fail to save the woman and the smoke asphyxiates you. You die in the flames, achieving nothing but an accidental casualty report.",
|
|
choices: &[],
|
|
is_terminal: true,
|
|
},
|
|
NodeDef {
|
|
id: "ENDING_HOUNDED_DEATH",
|
|
scene_title: None,
|
|
text: "The Mechanical Hound corners you. A cold procaine needle injects your leg. Your heart stops as the screen fades to cold static.",
|
|
choices: &[],
|
|
is_terminal: true,
|
|
},
|
|
NodeDef {
|
|
id: "ENDING_ELIMINATED",
|
|
scene_title: None,
|
|
text: "You are quietly escorted away to an anonymous state re-education asylum. Your records are completely scrubbed from history.",
|
|
choices: &[],
|
|
is_terminal: true,
|
|
},
|
|
NodeDef {
|
|
id: "ENDING_TELEVISED_EXECUTION",
|
|
scene_title: None,
|
|
text: "The Hound strikes you down on live television. Millions watch your final defiant monologue. You are dead, but you planted the seeds of doubt.",
|
|
choices: &[],
|
|
is_terminal: true,
|
|
},
|
|
NodeDef {
|
|
id: "ENDING_DEFENDING_FABER",
|
|
scene_title: None,
|
|
text: "You hold the door so Faber can flee out the back. You take a fatal blast from state enforcement squads, dying to preserve the old generation.",
|
|
choices: &[],
|
|
is_terminal: true,
|
|
},
|
|
NodeDef {
|
|
id: "ENDING_TRAITOR_EXECUTION",
|
|
scene_title: None,
|
|
text: "Mildred alerts an active patrol hovercraft overhead. They eliminate you both on the front lawn as public enemies.",
|
|
choices: &[],
|
|
is_terminal: true,
|
|
},
|
|
NodeDef {
|
|
id: "ENDING_SILENT_SUICIDE",
|
|
scene_title: None,
|
|
text: "You die silently in the transit terminal. The police never find Faber, and your secrets die with you.",
|
|
choices: &[],
|
|
is_terminal: true,
|
|
},
|
|
NodeDef {
|
|
id: "ENDING_BETRAYER",
|
|
scene_title: None,
|
|
text: "You save your skin but betray Faber. You are forced to watch his library burn before they execute you anyway for your crimes.",
|
|
choices: &[],
|
|
is_terminal: true,
|
|
},
|
|
NodeDef {
|
|
id: "ENDING_RAIL_DEATH",
|
|
scene_title: None,
|
|
text: "You die instantly on the high-voltage tracks. The evening news reports you as an unnamed, unhinged vagrant to avoid reporting the book.",
|
|
choices: &[],
|
|
is_terminal: true,
|
|
},
|
|
NodeDef {
|
|
id: "ENDING_INSIDER_SABOTAGE",
|
|
scene_title: None,
|
|
text: "You spend years quietly misrouting alarms and corrupting databases from within the department before being discovered and quietly eliminated.",
|
|
choices: &[],
|
|
is_terminal: true,
|
|
},
|
|
NodeDef {
|
|
id: "ENDING_CYNIC_DESERTION",
|
|
scene_title: None,
|
|
text: "You wander alone away from both the rebels and the city. When the bombs fall, you survive in the radioactive wilderness, entirely alone.",
|
|
choices: &[],
|
|
is_terminal: true,
|
|
},
|
|
];
|
|
|
|
struct FahrenheitGame {
|
|
current_node: &'static str,
|
|
score: usize,
|
|
}
|
|
|
|
fn route_label(node_id: &str) -> &'static str {
|
|
if node_id.starts_with("SCENE_3A") || node_id == "SCENE_4A_THE_HUNT" {
|
|
"Route Alpha: The Tyrant's Path"
|
|
} else if node_id == "SCENE_3B_TRAP" || node_id.starts_with("SCENE_4A") {
|
|
"Route Beta: The Martyr's Fire"
|
|
} else if node_id.starts_with("SCENE_3C") || node_id == "SCENE_4B_THE_RIVER" {
|
|
"Route Gamma: The Intellectual Underground"
|
|
} else if node_id == "SCENE_3D_PARLOR_DENUNCIATION" {
|
|
"Route Delta: The Broken Screen"
|
|
} else if node_id == "SCENE_3E_SUBWAY_ARREST" {
|
|
"Route Epsilon: The Platform Corner"
|
|
} else {
|
|
""
|
|
}
|
|
}
|
|
|
|
fn render_wrapped(start_row: usize, text: &str, color: ColorCode, max_width: usize) -> usize {
|
|
let bytes = text.as_bytes();
|
|
let len = bytes.len();
|
|
let mut row = start_row;
|
|
let mut pos = 0;
|
|
|
|
while pos < len && row < vga_buffer::HEIGHT {
|
|
let remaining = len - pos;
|
|
let line_len = if remaining <= max_width {
|
|
remaining
|
|
} else {
|
|
let mut break_pos = pos + max_width;
|
|
while break_pos > pos && bytes[break_pos] != b' ' {
|
|
break_pos -= 1;
|
|
}
|
|
if break_pos == pos {
|
|
max_width
|
|
} else {
|
|
break_pos - pos
|
|
}
|
|
};
|
|
|
|
let line = &text[pos..pos + line_len];
|
|
let line = line.trim_end();
|
|
if !line.is_empty() {
|
|
let col = (vga_buffer::WIDTH - line.len()) / 2;
|
|
vga_buffer::write_str(row, col, line, color);
|
|
}
|
|
row += 1;
|
|
|
|
pos += line_len;
|
|
if pos < len && bytes[pos] == b' ' {
|
|
pos += 1;
|
|
}
|
|
}
|
|
|
|
row
|
|
}
|
|
|
|
fn write_number(row: usize, col: usize, num: usize, color: ColorCode) -> usize {
|
|
let mut n = num;
|
|
let mut c = col;
|
|
if n == 0 {
|
|
vga_buffer::write_char(row, c, b'0', color);
|
|
return c + 1;
|
|
}
|
|
let mut digits = [b'0'; 10];
|
|
let mut len = 0;
|
|
while n > 0 {
|
|
digits[len] = b'0' + (n % 10) as u8;
|
|
n /= 10;
|
|
len += 1;
|
|
}
|
|
for i in (0..len).rev() {
|
|
vga_buffer::write_char(row, c, digits[i], color);
|
|
c += 1;
|
|
}
|
|
c
|
|
}
|
|
|
|
fn render_game(game: &FahrenheitGame) {
|
|
vga_buffer::clear_screen(Color::Black);
|
|
|
|
let node = get_node(game.current_node);
|
|
let white = ColorCode::new(Color::White, Color::Black);
|
|
let yellow = ColorCode::new(Color::Yellow, Color::Black);
|
|
let cyan = ColorCode::new(Color::Cyan, Color::Black);
|
|
let dgray = ColorCode::new(Color::DarkGray, Color::Black);
|
|
|
|
if node.is_terminal {
|
|
let title = "GAME OVER";
|
|
let col = (vga_buffer::WIDTH - title.len()) / 2;
|
|
vga_buffer::write_str(2, col, title, yellow);
|
|
|
|
let end_row = render_wrapped(5, node.text, white, 74);
|
|
|
|
let mut c = (vga_buffer::WIDTH - 24) / 2;
|
|
let score_label = "LITERARY SCORE: ";
|
|
vga_buffer::write_str(end_row + 1, c, score_label, cyan);
|
|
c += score_label.len();
|
|
c = write_number(end_row + 1, c, game.score, cyan);
|
|
vga_buffer::write_str(end_row + 1, c, "/100", cyan);
|
|
|
|
let msg = "PRESS ANY KEY TO RETURN";
|
|
let col = (vga_buffer::WIDTH - msg.len()) / 2;
|
|
vga_buffer::write_str(end_row + 3, col, msg, dgray);
|
|
} else {
|
|
if let Some(title) = node.scene_title {
|
|
let col = (vga_buffer::WIDTH - title.len()) / 2;
|
|
vga_buffer::write_str(0, col, title, yellow);
|
|
}
|
|
|
|
let text_end = render_wrapped(2, node.text, white, 74);
|
|
|
|
let mut choice_row = text_end + 1;
|
|
for i in 0..node.choices.len() {
|
|
let choice = &node.choices[i];
|
|
let prefix = match i {
|
|
0 => "1. ",
|
|
1 => "2. ",
|
|
_ => "3. ",
|
|
};
|
|
let mut buf = [0u8; 80];
|
|
let mut len = 0;
|
|
for &b in prefix.as_bytes() {
|
|
buf[len] = b;
|
|
len += 1;
|
|
}
|
|
for &b in choice.text.as_bytes() {
|
|
if len < 80 {
|
|
buf[len] = b;
|
|
len += 1;
|
|
}
|
|
}
|
|
let line = core::str::from_utf8(&buf[..len]).unwrap();
|
|
let col = (vga_buffer::WIDTH - line.len()) / 2;
|
|
vga_buffer::write_str(choice_row, col, line, white);
|
|
choice_row += 1;
|
|
}
|
|
|
|
let route = route_label(game.current_node);
|
|
if !route.is_empty() {
|
|
let sep = "----------------------------------------";
|
|
let col = (vga_buffer::WIDTH - sep.len()) / 2;
|
|
vga_buffer::write_str(choice_row + 1, col, sep, dgray);
|
|
let col = (vga_buffer::WIDTH - route.len()) / 2;
|
|
vga_buffer::write_str(choice_row + 2, col, route, cyan);
|
|
}
|
|
|
|
let score_text = "SCORE: ";
|
|
let mut c = (vga_buffer::WIDTH - 20) / 2;
|
|
vga_buffer::write_str(choice_row + 4, c, score_text, cyan);
|
|
c += score_text.len();
|
|
write_number(choice_row + 4, c, game.score, cyan);
|
|
|
|
let msg = "PRESS 1-3 TO CHOOSE ~ TO EXIT";
|
|
let col = (vga_buffer::WIDTH - msg.len()) / 2;
|
|
vga_buffer::write_str(choice_row + 6, col, msg, dgray);
|
|
}
|
|
}
|
|
|
|
pub fn run_fahrenheit() {
|
|
let mut game = FahrenheitGame {
|
|
current_node: "START",
|
|
score: 0,
|
|
};
|
|
|
|
loop {
|
|
render_game(&game);
|
|
let node = get_node(game.current_node);
|
|
|
|
if node.is_terminal {
|
|
sound::game_over_beep();
|
|
loop {
|
|
if keyboard::poll_key().is_some() {
|
|
return;
|
|
}
|
|
core::hint::spin_loop();
|
|
}
|
|
}
|
|
|
|
loop {
|
|
if let Some(action) = keyboard::poll_key() {
|
|
match action {
|
|
KeyAction::Tilde => return,
|
|
KeyAction::Digit(d) if d >= 1 && d <= 3 => {
|
|
let idx = (d - 1) as usize;
|
|
if idx < node.choices.len() {
|
|
let choice = &node.choices[idx];
|
|
game.score += choice.score_modifier;
|
|
if game.score > 100 {
|
|
game.score = 100;
|
|
}
|
|
sound::select_beep();
|
|
game.current_node = choice.next_node;
|
|
break;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
core::hint::spin_loop();
|
|
}
|
|
}
|
|
}
|