зеркало из
https://github.com/kodackx/disinformation-quest.git
synced 2025-10-29 20:46:05 +02:00
revamp ending and animation
Этот коммит содержится в:
родитель
0d995818ab
Коммит
d39044699d
Двоичные данные
public/final-theme.mp3
Обычный файл
Двоичные данные
public/final-theme.mp3
Обычный файл
Двоичный файл не отображается.
@ -1,21 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { startBackgroundMusic } from "@/utils/audio";
|
||||
|
||||
interface GameBackgroundProps {
|
||||
shouldStartAudio?: boolean;
|
||||
}
|
||||
|
||||
export const GameBackground = ({ shouldStartAudio = false }: GameBackgroundProps) => {
|
||||
const [audioStarted, setAudioStarted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldStartAudio && !audioStarted) {
|
||||
const audio = new Audio("/tension-background.mp3");
|
||||
audio.loop = true;
|
||||
audio.volume = 0.3;
|
||||
audio.play().catch(console.error);
|
||||
setAudioStarted(true);
|
||||
if (shouldStartAudio) {
|
||||
startBackgroundMusic();
|
||||
}
|
||||
}, [shouldStartAudio, audioStarted]);
|
||||
}, [shouldStartAudio]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 -z-10 overflow-hidden">
|
||||
|
||||
41
src/components/MuteButton.tsx
Обычный файл
41
src/components/MuteButton.tsx
Обычный файл
@ -0,0 +1,41 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SpeakerWaveIcon, SpeakerXMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getMuted, setMuted } from '@/utils/audio';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
export const MuteButton: React.FC = () => {
|
||||
const [isMuted, setIsMuted] = useState(getMuted());
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
setMuted(isMuted);
|
||||
}, [isMuted]);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm transition-all duration-300 border border-yellow-500/20 hover:border-yellow-500/40"
|
||||
onClick={() => setIsMuted(!isMuted)}
|
||||
>
|
||||
{isMuted ? (
|
||||
<SpeakerXMarkIcon className="h-4 w-4 text-yellow-500" />
|
||||
) : (
|
||||
<SpeakerWaveIcon className="h-4 w-4 text-yellow-500" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{isMuted ? t('audio.unmute') : t('audio.mute')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@ -1,9 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Play, Pause } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { playRecordingSound } from "@/utils/audio";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlayIcon, PauseIcon } from "@heroicons/react/24/outline";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { playBriefing } from "@/utils/audio";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
|
||||
interface BriefingAudioProps {
|
||||
stage: string;
|
||||
@ -11,47 +11,10 @@ interface BriefingAudioProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const BriefingAudio = ({ stage, audioRef, className }: BriefingAudioProps) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
export const BriefingAudio = ({ stage, audioRef, className = "" }: BriefingAudioProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
} else {
|
||||
playRecordingSound();
|
||||
audioRef.current.play();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
const handleEnded = () => {
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
setIsPlaying(true);
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
audioRef.current.addEventListener('ended', handleEnded);
|
||||
audioRef.current.addEventListener('play', handlePlay);
|
||||
audioRef.current.addEventListener('pause', handlePause);
|
||||
|
||||
return () => {
|
||||
if (!audioRef.current) return;
|
||||
audioRef.current.removeEventListener('ended', handleEnded);
|
||||
audioRef.current.removeEventListener('play', handlePlay);
|
||||
audioRef.current.removeEventListener('pause', handlePause);
|
||||
};
|
||||
}, [audioRef]);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentAudio, setCurrentAudio] = useState<HTMLAudioElement | null>(null);
|
||||
|
||||
const getAudioFileName = (stage: string) => {
|
||||
const currentLanguage = i18n.language;
|
||||
@ -68,27 +31,62 @@ export const BriefingAudio = ({ stage, audioRef, className }: BriefingAudioProps
|
||||
return `${monthKey}-${currentLanguage}.mp3`;
|
||||
};
|
||||
|
||||
// Only skip rendering for INTRO stage
|
||||
if (stage === "INTRO") {
|
||||
return null;
|
||||
const handlePlayPause = async () => {
|
||||
try {
|
||||
if (isPlaying && currentAudio) {
|
||||
currentAudio.pause();
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentAudio) {
|
||||
currentAudio.play();
|
||||
setIsPlaying(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const audioPath = `/audio/briefings/${getAudioFileName(stage)}`;
|
||||
console.log('Playing audio:', audioPath);
|
||||
|
||||
const newAudio = playBriefing(audioPath);
|
||||
newAudio.addEventListener('ended', () => setIsPlaying(false));
|
||||
newAudio.addEventListener('pause', () => setIsPlaying(false));
|
||||
newAudio.addEventListener('play', () => setIsPlaying(true));
|
||||
setCurrentAudio(newAudio);
|
||||
setIsPlaying(true);
|
||||
} catch (error) {
|
||||
console.error('Audio error:', error);
|
||||
toast({
|
||||
title: "Audio Error",
|
||||
description: `Failed to play briefing: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (currentAudio) {
|
||||
currentAudio.pause();
|
||||
}
|
||||
};
|
||||
}, [currentAudio]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center", className)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-yellow-500 hover:text-yellow-400 ${className}`}
|
||||
onClick={handlePlayPause}
|
||||
className="bg-black/50 border-yellow-500/50 text-yellow-500 hover:text-yellow-400 hover:border-yellow-400 hover:bg-black/60 flex items-center gap-2"
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
<span className="text-xs font-medium">{t('audio.briefing')}</span>
|
||||
{isPlaying ? (
|
||||
<PauseIcon className="w-3 h-3 mr-1" />
|
||||
) : (
|
||||
<PlayIcon className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
<span className="text-xs">
|
||||
{isPlaying ? 'Pause' : 'Play'}
|
||||
</span>
|
||||
</Button>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={`/audio/briefings/${getAudioFileName(stage)}`}
|
||||
preload="auto"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -4,7 +4,8 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Choice } from './types';
|
||||
import { ChoiceID } from './constants/metrics';
|
||||
import { ArrowTrendingUpIcon, ExclamationTriangleIcon, LockClosedIcon, InformationCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { ArrowTrendingUpIcon, ExclamationTriangleIcon, LockClosedIcon } from '@heroicons/react/24/outline';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ChoiceCardProps {
|
||||
choice: Choice;
|
||||
@ -21,6 +22,7 @@ export const ChoiceCard: React.FC<ChoiceCardProps> = ({
|
||||
disabled = false,
|
||||
optionNumber
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const strengtheningChoices = choice.strengthenedBy?.filter(c => previousChoices.includes(c)) || [];
|
||||
const weakeningChoices = choice.weakenedBy?.filter(c => previousChoices.includes(c)) || [];
|
||||
|
||||
@ -34,47 +36,40 @@ export const ChoiceCard: React.FC<ChoiceCardProps> = ({
|
||||
duration-300
|
||||
hover:scale-[1.02]
|
||||
cursor-pointer
|
||||
mt-8
|
||||
${isStrengthened ? 'border-green-500 shadow-green-500/20 shadow-lg' : ''}
|
||||
${isWeakened ? 'border-orange-500 shadow-orange-500/20' : ''}
|
||||
${isLocked || disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
bg-gray-800/50 hover:bg-gray-700/50
|
||||
group
|
||||
`;
|
||||
|
||||
const getStatusMessage = () => {
|
||||
if (isStrengthened && isWeakened) {
|
||||
return "This choice is both enhanced and weakened by your previous choices";
|
||||
} else if (isStrengthened) {
|
||||
return "This choice is enhanced by your previous choices";
|
||||
} else if (isWeakened) {
|
||||
return "This choice is weakened by your previous choices";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const statusMessage = getStatusMessage();
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cardClasses}
|
||||
onClick={() => !isLocked && !disabled && onClick()}
|
||||
>
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-lg">
|
||||
<div className="absolute -left-3 -top-3 w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center text-white font-bold border-2 border-gray-600 shadow-lg z-10">
|
||||
{optionNumber}
|
||||
</div>
|
||||
|
||||
<CardHeader className="space-y-3 relative">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<CardTitle className="text-xl font-bold text-white group-hover:text-yellow-400 transition-colors">
|
||||
{choice.text}
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 flex-wrap justify-end">
|
||||
{isStrengthened && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge className="bg-green-500 hover:bg-green-600">
|
||||
<Badge className="bg-green-500/20 text-green-400 border border-green-500/50 hover:bg-green-500/30">
|
||||
<ArrowTrendingUpIcon className="w-4 h-4 mr-1" />
|
||||
Enhanced by previous strategy choice
|
||||
{t('analysis.badges.enhanced')}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="space-y-2">
|
||||
<p className="font-bold text-green-500">Enhanced by your choice:</p>
|
||||
<p className="font-bold text-green-500">{t('analysis.badges.enhancedBy')}</p>
|
||||
<ul className="list-disc pl-4">
|
||||
{strengtheningChoices.map(c => (
|
||||
<li key={c}>{c}</li>
|
||||
@ -88,14 +83,14 @@ export const ChoiceCard: React.FC<ChoiceCardProps> = ({
|
||||
{isWeakened && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge className="bg-orange-500 hover:bg-orange-600">
|
||||
<Badge className="bg-orange-500/20 text-orange-400 border border-orange-500/50 hover:bg-orange-500/30">
|
||||
<ExclamationTriangleIcon className="w-4 h-4 mr-1" />
|
||||
Weakened by previous strategy choice
|
||||
{t('analysis.badges.weakened')}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="space-y-2">
|
||||
<p className="font-bold text-orange-500">Weakened by your choice:</p>
|
||||
<p className="font-bold text-orange-500">{t('analysis.badges.weakenedBy')}</p>
|
||||
<ul className="list-disc pl-4">
|
||||
{weakeningChoices.map(c => (
|
||||
<li key={c}>{c}</li>
|
||||
@ -108,24 +103,20 @@ export const ChoiceCard: React.FC<ChoiceCardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <CardDescription className="text-gray-300">
|
||||
{choice.description}
|
||||
</CardDescription> */}
|
||||
<CardDescription className="text-gray-400 leading-relaxed">
|
||||
{choice.impact}
|
||||
<span className="block mt-2 text-sm text-gray-500">{t('analysis.clickToSeeDetails')}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{isLocked && (
|
||||
<div className="flex items-center text-gray-400 gap-2">
|
||||
<LockClosedIcon className="w-4 h-4" />
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center text-gray-400 gap-2 p-3 rounded-md bg-gray-900/50 border border-gray-700">
|
||||
<LockClosedIcon className="w-5 h-5 text-gray-500" />
|
||||
<span>Requires: {choice.requires?.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<InformationCircleIcon className="w-8 h-8 text-blue-500" />
|
||||
<span className="text-gray-300">{choice.impact}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
71
src/components/game/DevPanel.tsx
Обычный файл
71
src/components/game/DevPanel.tsx
Обычный файл
@ -0,0 +1,71 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { MONTHS } from "./constants/gameStages";
|
||||
import { ChoiceID } from "./constants/metrics";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DevPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onJumpToMonth: (monthIndex: number) => void;
|
||||
onRandomizeChoices: () => void;
|
||||
}
|
||||
|
||||
export const DevPanel = ({ open, onOpenChange, onJumpToMonth, onRandomizeChoices }: DevPanelProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Define the correct stage order
|
||||
const stageOrder = [
|
||||
'JANUARY',
|
||||
'MARCH',
|
||||
'MAY',
|
||||
'ALERT',
|
||||
'JULY',
|
||||
'SEPTEMBER',
|
||||
'NOVEMBER',
|
||||
'DECEMBER',
|
||||
'EXPOSÉ'
|
||||
] as const;
|
||||
|
||||
// Create a mapping of stage indices to their actual positions in the game
|
||||
const stageToIndex: { [key: string]: number } = {};
|
||||
stageOrder.forEach((stage, index) => {
|
||||
stageToIndex[stage] = index;
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-black/95 text-white border-emerald-900/50">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-emerald-500">Developer Controls</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-400">Jump to Stage</label>
|
||||
<Select onValueChange={(value) => onJumpToMonth(stageToIndex[value])}>
|
||||
<SelectTrigger className="bg-black/50 border-emerald-900">
|
||||
<SelectValue placeholder="Select stage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-black/95 border-emerald-900">
|
||||
{stageOrder.map((key) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{t(`months.${key.toLowerCase()}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={onRandomizeChoices}
|
||||
className="w-full bg-emerald-950/20 hover:bg-emerald-950/30 text-emerald-400 border border-emerald-500/50"
|
||||
>
|
||||
Randomize Previous Choices
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -46,7 +46,8 @@ export const DossierPanel = ({ entries, choices = [] }: DossierPanelProps) => {
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
className="bg-yellow-500 hover:bg-yellow-600 text-black"
|
||||
className="text-yellow-500 hover:bg-yellow-500 hover:text-black"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
<ClipboardList className="w-4 h-4 mr-2" />
|
||||
|
||||
104
src/components/game/EndGameDialog.tsx
Обычный файл
104
src/components/game/EndGameDialog.tsx
Обычный файл
@ -0,0 +1,104 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogPortal,
|
||||
DialogOverlay
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { switchToFinalMusic } from "@/utils/audio";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface EndGameDialogProps {
|
||||
onContinue: () => void;
|
||||
startFade: boolean;
|
||||
}
|
||||
|
||||
export const EndGameDialog = ({ onContinue, startFade }: EndGameDialogProps) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
switchToFinalMusic();
|
||||
}, []);
|
||||
|
||||
const messages = [
|
||||
t('endGame.message1'),
|
||||
t('endGame.message2'),
|
||||
t('endGame.message3')
|
||||
];
|
||||
|
||||
const handleNext = () => {
|
||||
if (step < messages.length - 1) {
|
||||
setStep(step + 1);
|
||||
} else {
|
||||
setOpen(false);
|
||||
setTimeout(() => {
|
||||
onContinue();
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogPortal>
|
||||
<DialogOverlay className="z-[45]" />
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"bg-black/95 text-white border-emerald-900/50 max-w-2xl [&>button]:hidden",
|
||||
"z-[50] fixed left-[50%] top-[50%] grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg"
|
||||
)}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-emerald-500 text-2xl mb-6">
|
||||
{t('endGame.title')}
|
||||
</DialogTitle>
|
||||
|
||||
<div className="space-y-6">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={step}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="min-h-[100px] flex items-center justify-center text-center"
|
||||
>
|
||||
<p className="text-lg text-emerald-200">
|
||||
{messages[step]}
|
||||
</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex justify-center mt-8">
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
className="bg-emerald-950/20 hover:bg-emerald-950/30 text-emerald-400 border border-emerald-500/50 font-semibold py-6 px-8 text-lg"
|
||||
>
|
||||
{step < messages.length - 1 ? t('buttons.continue') : t('buttons.viewReport')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="absolute -z-10 inset-0 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-emerald-950/20 via-black/40 to-black/60 animate-pulse-slow"></div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -1,14 +1,29 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Special+Elite&display=swap');
|
||||
|
||||
.expert-memo {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
background-color: #1a1715;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px),
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
rgba(255, 255, 255, 0.008) 0px,
|
||||
rgba(255, 255, 255, 0.008) 1px,
|
||||
transparent 1px,
|
||||
transparent 4px
|
||||
);
|
||||
background-size: 20px 20px, 20px 20px, 4px 4px;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgb(234 179 8);
|
||||
font-family: monospace;
|
||||
box-shadow: 0 0 10px rgba(234, 179, 8, 0.2);
|
||||
box-shadow:
|
||||
0 0 10px rgba(234, 179, 8, 0.2),
|
||||
inset 0 0 60px rgba(0, 0, 0, 0.6);
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
color: #e8e8e8;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@ -49,6 +64,7 @@
|
||||
.memo-label.standard {
|
||||
background-color: rgb(234 179 8);
|
||||
color: black;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.memo-label.urgent {
|
||||
@ -63,17 +79,19 @@
|
||||
.field-label {
|
||||
font-weight: bold;
|
||||
margin-right: 0.5rem;
|
||||
min-width: 80px;
|
||||
min-width: 140px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.field-content {
|
||||
font-weight: normal;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.memo-body {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
text-shadow: 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.memo-body p {
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
import React from 'react';
|
||||
import './ExpertMemo.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BriefingAudio } from './BriefingAudio';
|
||||
|
||||
interface ExpertMemoProps {
|
||||
from: string;
|
||||
subject: string;
|
||||
children: React.ReactNode;
|
||||
isAlert?: boolean;
|
||||
stage?: string;
|
||||
audioRef?: React.RefObject<HTMLAudioElement>;
|
||||
}
|
||||
|
||||
export const ExpertMemo: React.FC<ExpertMemoProps> = ({ from, subject, children, isAlert = false }) => {
|
||||
export const ExpertMemo: React.FC<ExpertMemoProps> = ({ from, subject, children, isAlert = false, stage, audioRef }) => {
|
||||
const { t } = useTranslation();
|
||||
const highlightColor = isAlert ? 'text-red-500' : 'text-yellow-500';
|
||||
const memoClass = isAlert ? 'expert-memo alert' : 'expert-memo';
|
||||
@ -29,7 +32,7 @@ export const ExpertMemo: React.FC<ExpertMemoProps> = ({ from, subject, children,
|
||||
<div className={memoClass}>
|
||||
<div className="memo-header">
|
||||
{isAlert ? (
|
||||
<div className="memo-label urgent animate-pulse">{t('memo.urgentInput')}</div>
|
||||
<div className="memo-label urgent">{t('memo.urgentInput')}</div>
|
||||
) : (
|
||||
<div className="memo-label standard">{t('memo.expertNote')}</div>
|
||||
)}
|
||||
@ -41,6 +44,14 @@ export const ExpertMemo: React.FC<ExpertMemoProps> = ({ from, subject, children,
|
||||
<span className={`field-label ${highlightColor}`}>SUBJECT:</span>
|
||||
<span className={`field-content ${highlightColor}`}>{subject}</span>
|
||||
</div>
|
||||
{stage && audioRef && (
|
||||
<div className="memo-field flex items-center">
|
||||
<span className={`field-label ${highlightColor}`}>BRIEFING AUDIO:</span>
|
||||
<span className="field-content flex items-center -mt-px">
|
||||
<BriefingAudio stage={stage} audioRef={audioRef} className="!p-0 !bg-transparent !border-0" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="memo-body text-gray-300">
|
||||
{formatContent(children)}
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Shield, Star, Target, TrendingUp, Award, RotateCcw, Download } from "lucide-react";
|
||||
import { Shield, Star, Target, TrendingUp, Award, RotateCcw, Download, Share2 } from "lucide-react";
|
||||
import { generateFinalReport } from "./constants";
|
||||
import { MetricsDisplay } from "./MetricsDisplay";
|
||||
import html2canvas from 'html2canvas';
|
||||
import "./FinalMemo.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChoiceID } from './constants/metrics';
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EndGameDialog } from "./EndGameDialog";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface FinalMemoProps {
|
||||
choices: string[];
|
||||
@ -16,6 +20,16 @@ interface FinalMemoProps {
|
||||
export const FinalMemo = ({ choices, onRestart, agentNumber }: FinalMemoProps) => {
|
||||
const finalReport = generateFinalReport(choices as ChoiceID[]);
|
||||
const { t } = useTranslation();
|
||||
const [showReport, setShowReport] = useState(false);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Add a small delay before showing the dialog to ensure fade is complete
|
||||
const timer = setTimeout(() => {
|
||||
setShowDialog(true);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleDownload = async () => {
|
||||
const reportElement = document.querySelector('.final-memo');
|
||||
@ -38,10 +52,60 @@ export const FinalMemo = ({ choices, onRestart, agentNumber }: FinalMemoProps) =
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
const reportElement = document.querySelector('.final-memo');
|
||||
if (!reportElement) return;
|
||||
|
||||
try {
|
||||
const canvas = await html2canvas(reportElement as HTMLElement, {
|
||||
backgroundColor: '#000000',
|
||||
scale: 2,
|
||||
logging: false,
|
||||
});
|
||||
|
||||
const blob = await new Promise<Blob>((resolve) => {
|
||||
canvas.toBlob((blob) => resolve(blob!), 'image/png');
|
||||
});
|
||||
|
||||
if (navigator.share) {
|
||||
const file = new File([blob], 'disinformation-quest-report.png', { type: 'image/png' });
|
||||
const shareData = {
|
||||
title: t('share.title'),
|
||||
text: `${t('share.text')}\n\n${t('share.metrics')}\nVirality: ${finalReport.metrics.virality}x\nReach: ${finalReport.metrics.reach}%\nLoyalists: ${finalReport.metrics.loyalists}%\n\n${t('share.playNow')}`,
|
||||
files: [file],
|
||||
url: window.location.href
|
||||
};
|
||||
|
||||
try {
|
||||
await navigator.share(shareData);
|
||||
} catch (err) {
|
||||
// Fallback if sharing with both text and file fails, try without URL
|
||||
delete shareData.url;
|
||||
await navigator.share(shareData);
|
||||
}
|
||||
} else {
|
||||
// Fallback for browsers that don't support Web Share API
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sharing report:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden">
|
||||
<div className="relative min-h-screen bg-black/80 p-4 flex items-center justify-center">
|
||||
<Card className="w-full max-w-4xl mx-auto final-memo">
|
||||
<>
|
||||
{showDialog && (
|
||||
<EndGameDialog onContinue={() => setShowReport(true)} startFade={false} />
|
||||
)}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: showReport ? 1 : 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="relative min-h-screen bg-black/90 p-4 flex flex-col items-center pt-8 z-[60] overflow-y-auto"
|
||||
>
|
||||
<Card className="w-full max-w-4xl final-memo relative mb-8">
|
||||
<CardHeader className="space-y-4 border-b border-emerald-900/30">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
@ -128,26 +192,34 @@ export const FinalMemo = ({ choices, onRestart, agentNumber }: FinalMemoProps) =
|
||||
</section>
|
||||
|
||||
<div className="flex justify-center gap-4 pt-6 border-t border-emerald-900/30">
|
||||
<button
|
||||
<Button
|
||||
onClick={onRestart}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-950/50 hover:bg-emerald-950/70
|
||||
text-emerald-400 rounded-md transition-colors duration-200"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
{t('finalReport.ui.beginNewMission')}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-950/50 hover:bg-emerald-950/70
|
||||
text-emerald-400 rounded-md transition-colors duration-200"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t('finalReport.ui.downloadReport')}
|
||||
</button>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleShare}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-950/50 hover:bg-emerald-950/70
|
||||
text-emerald-400 rounded-md transition-colors duration-200"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
{t('finalReport.ui.shareReport')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,10 +1,8 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
@ -26,48 +24,45 @@ export const IntroDialog = ({ onStartAudio }: IntroDialogProps) => {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="bg-black/90 text-white border-gray-700 max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="bg-black/90 text-white border-gray-700 max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-yellow-500 text-2xl font-bold">
|
||||
<DialogTitle className="text-yellow-500 text-2xl mb-6">
|
||||
{t('intro.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-200 space-y-6 mt-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
|
||||
<div className="space-y-6 text-gray-200">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-4xl">🎯</div>
|
||||
<p className="text-lg">
|
||||
<p className="text-lg font-medium">
|
||||
{t('intro.mission')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-yellow-500 pl-4 py-2 bg-yellow-500/10">
|
||||
<p className="text-lg">
|
||||
<p className="text-base">
|
||||
{t('intro.explanation')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800/50 p-4 rounded-lg">
|
||||
<h3 className="text-yellow-500 font-semibold mb-2">{t('intro.howToPlay.title')}</h3>
|
||||
<p>
|
||||
<p className="text-base">
|
||||
{t('intro.howToPlay.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-yellow-500 font-medium">
|
||||
<p className="text-yellow-500 text-sm">
|
||||
{t('intro.reminder')}
|
||||
</p>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col items-center gap-6 mt-8">
|
||||
<div className="flex items-center gap-2 self-start">
|
||||
<LanguageSwitcher />
|
||||
<span className="text-xs text-gray-400 max-w-[200px] leading-tight">
|
||||
<span className="text-xs text-gray-400">
|
||||
{t('languageSwitcher.hint')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleBeginSimulation}
|
||||
className="bg-yellow-500 hover:bg-yellow-600 text-black font-semibold sm:w-auto"
|
||||
className="bg-yellow-500 hover:bg-yellow-600 text-black font-semibold w-full py-6 text-lg"
|
||||
>
|
||||
{t('buttons.beginSimulation')}
|
||||
</Button>
|
||||
|
||||
@ -1,20 +1,31 @@
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
message: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export const LoadingOverlay = ({ message, progress }: LoadingOverlayProps) => (
|
||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/80 flex items-center justify-center z-[45]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="max-w-md w-full space-y-4 p-6">
|
||||
<div className="space-y-2">
|
||||
<p className="text-yellow-500 font-mono text-lg text-center">{message}</p>
|
||||
<div className="w-full bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-yellow-500 h-2 rounded-full transition-all duration-300"
|
||||
<motion.div
|
||||
className="bg-yellow-500 h-2 rounded-full"
|
||||
style={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
@ -18,7 +18,7 @@ export const MONTHS = {
|
||||
} as const;
|
||||
|
||||
// Create a custom hook to handle stages with translations
|
||||
export const useGameStages = (): GameStage[] => {
|
||||
export const useGameStages = (audioRef: React.RefObject<HTMLAudioElement>): GameStage[] => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Helper function to get translated month title
|
||||
@ -39,7 +39,9 @@ export const useGameStages = (): GameStage[] => {
|
||||
title: getMonthTitle(MONTHS.JANUARY),
|
||||
description: <ExpertMemo
|
||||
from={t('stages.1.expertMemo.from')}
|
||||
subject={t('stages.1.expertMemo.subject')}>
|
||||
subject={t('stages.1.expertMemo.subject')}
|
||||
stage="1"
|
||||
audioRef={audioRef}>
|
||||
<p>{t('stages.1.expertMemo.content.greeting')}</p>
|
||||
|
||||
<p>{t('stages.1.expertMemo.content.intro')}</p>
|
||||
@ -116,7 +118,9 @@ export const useGameStages = (): GameStage[] => {
|
||||
title: getMonthTitle(MONTHS.MARCH),
|
||||
description: <ExpertMemo
|
||||
from={t('stages.2.expertMemo.from')}
|
||||
subject={t('stages.2.expertMemo.subject')}>
|
||||
subject={t('stages.2.expertMemo.subject')}
|
||||
stage="2"
|
||||
audioRef={audioRef}>
|
||||
<p>{t('stages.2.expertMemo.content.greeting')}</p>
|
||||
|
||||
<p>{t('stages.2.expertMemo.content.intro')}</p>
|
||||
@ -191,7 +195,9 @@ export const useGameStages = (): GameStage[] => {
|
||||
title: getMonthTitle(MONTHS.MAY),
|
||||
description: <ExpertMemo
|
||||
from={t('stages.3.expertMemo.from')}
|
||||
subject={t('stages.3.expertMemo.subject')}>
|
||||
subject={t('stages.3.expertMemo.subject')}
|
||||
stage="3"
|
||||
audioRef={audioRef}>
|
||||
<p>{t('stages.3.expertMemo.content.greeting')}</p>
|
||||
|
||||
<p>{t('stages.3.expertMemo.content.intro')}</p>
|
||||
@ -268,7 +274,9 @@ export const useGameStages = (): GameStage[] => {
|
||||
description: <ExpertMemo
|
||||
from={t('stages.4.expertMemo.from')}
|
||||
subject={t('stages.4.expertMemo.subject')}
|
||||
isAlert={true}>
|
||||
isAlert={true}
|
||||
stage="4"
|
||||
audioRef={audioRef}>
|
||||
<p>{t('stages.4.expertMemo.content.greeting')}</p>
|
||||
|
||||
<p>{t('stages.4.expertMemo.content.intro')}</p>
|
||||
@ -344,7 +352,9 @@ export const useGameStages = (): GameStage[] => {
|
||||
title: getMonthTitle(MONTHS.JULY),
|
||||
description: <ExpertMemo
|
||||
from={t('stages.5.expertMemo.from')}
|
||||
subject={t('stages.5.expertMemo.subject')}>
|
||||
subject={t('stages.5.expertMemo.subject')}
|
||||
stage="5"
|
||||
audioRef={audioRef}>
|
||||
<p>{t('stages.5.expertMemo.content.greeting')}</p>
|
||||
|
||||
<p>{t('stages.5.expertMemo.content.intro')}</p>
|
||||
@ -420,7 +430,9 @@ export const useGameStages = (): GameStage[] => {
|
||||
title: getMonthTitle(MONTHS.SEPTEMBER),
|
||||
description: <ExpertMemo
|
||||
from={t('stages.6.expertMemo.from')}
|
||||
subject={t('stages.6.expertMemo.subject')}>
|
||||
subject={t('stages.6.expertMemo.subject')}
|
||||
stage="6"
|
||||
audioRef={audioRef}>
|
||||
<p>{t('stages.6.expertMemo.content.greeting')}</p>
|
||||
|
||||
<p>{t('stages.6.expertMemo.content.intro')}</p>
|
||||
@ -496,7 +508,9 @@ export const useGameStages = (): GameStage[] => {
|
||||
title: getMonthTitle(MONTHS.NOVEMBER),
|
||||
description: <ExpertMemo
|
||||
from={t('stages.7.expertMemo.from')}
|
||||
subject={t('stages.7.expertMemo.subject')}>
|
||||
subject={t('stages.7.expertMemo.subject')}
|
||||
stage="7"
|
||||
audioRef={audioRef}>
|
||||
<p>{t('stages.7.expertMemo.content.greeting')}</p>
|
||||
|
||||
<p>{t('stages.7.expertMemo.content.intro')}</p>
|
||||
@ -572,7 +586,9 @@ export const useGameStages = (): GameStage[] => {
|
||||
title: getMonthTitle(MONTHS.DECEMBER),
|
||||
description: <ExpertMemo
|
||||
from={t('stages.8.expertMemo.from')}
|
||||
subject={t('stages.8.expertMemo.subject')}>
|
||||
subject={t('stages.8.expertMemo.subject')}
|
||||
stage="8"
|
||||
audioRef={audioRef}>
|
||||
<p>{t('stages.8.expertMemo.content.greeting')}</p>
|
||||
|
||||
<p>{t('stages.8.expertMemo.content.intro')}</p>
|
||||
@ -649,7 +665,9 @@ export const useGameStages = (): GameStage[] => {
|
||||
description: <ExpertMemo
|
||||
from={t('stages.9.expertMemo.from')}
|
||||
subject={t('stages.9.expertMemo.subject')}
|
||||
isAlert={true}>
|
||||
isAlert={true}
|
||||
stage="9"
|
||||
audioRef={audioRef}>
|
||||
<p>{t('stages.9.expertMemo.content.greeting')}</p>
|
||||
|
||||
<p>{t('stages.9.expertMemo.content.intro')}</p>
|
||||
|
||||
@ -17,16 +17,15 @@ export enum ChoiceID {
|
||||
ESTABLISH_MEMES = 'establish_memes',
|
||||
LAUNCH_NEWS = 'launch_news',
|
||||
INFILTRATE_COMMUNITIES = 'infiltrate_communities',
|
||||
INFLUENCER_COLLABORATION = 'influencer_collaboration',
|
||||
GRASSROOTS_MOVEMENT = 'grassroots_movement',
|
||||
STAY_COURSE = 'stay_course',
|
||||
COUNTER_CAMPAIGN = 'counter_campaign',
|
||||
EXPERT_PANEL = 'expert_panel',
|
||||
ACADEMIC_OUTREACH = 'academic_outreach',
|
||||
RESEARCH_PAPER = 'research_paper',
|
||||
CONSPIRACY_DOCUMENTARY = 'conspiracy_documentary',
|
||||
PODCAST_PLATFORMS = 'podcast_platforms',
|
||||
// New ones to add
|
||||
INFLUENCER_COLLABORATION = 'influencer_collaboration',
|
||||
GRASSROOTS_MOVEMENT = 'grassroots_movement',
|
||||
EXPERT_PANEL = 'expert_panel',
|
||||
ACADEMIC_OUTREACH = 'academic_outreach',
|
||||
CELEBRITY_ENDORSEMENT = 'celebrity_endorsement',
|
||||
EVENT_STRATEGY = 'event_strategy',
|
||||
PLATFORM_POLICY = 'platform_policy',
|
||||
@ -205,8 +204,6 @@ const STRENGTHEN_MULTIPLIER = 1.25;
|
||||
const WEAKEN_MULTIPLIER = 0.75;
|
||||
|
||||
export const calculateMetrics = (choiceIds: ChoiceID[] = []): MetricImpact => {
|
||||
console.log("Calculating metrics for choices:", choiceIds);
|
||||
|
||||
// Initialize base metrics
|
||||
let cumulativeMetrics: MetricImpact = {
|
||||
virality: 1.0,
|
||||
@ -245,9 +242,17 @@ export const calculateMetrics = (choiceIds: ChoiceID[] = []): MetricImpact => {
|
||||
});
|
||||
|
||||
// Round and clamp values
|
||||
return {
|
||||
const finalMetrics = {
|
||||
virality: Number(cumulativeMetrics.virality.toFixed(1)),
|
||||
reach: Math.min(100, Math.max(0, Math.round(cumulativeMetrics.reach))),
|
||||
loyalists: Math.min(100, Math.max(0, Math.round(cumulativeMetrics.loyalists)))
|
||||
};
|
||||
|
||||
console.log(`Calculating metrics for choices: ${choiceIds.join(', ')}`);
|
||||
console.log('\nMetrics:',
|
||||
`Virality ${finalMetrics.virality}x`,
|
||||
`Reach ${finalMetrics.reach}%`,
|
||||
`Loyalists ${finalMetrics.loyalists}%`
|
||||
);
|
||||
return finalMetrics;
|
||||
};
|
||||
@ -23,7 +23,7 @@ const CardHeader = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
className={cn("flex flex-col space-y-1.5 p-6 md:p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@ -38,13 +38,20 @@
|
||||
"urgentInput": "URGENT INPUT NEEDED"
|
||||
},
|
||||
"audio": {
|
||||
"briefing": "Play Briefing"
|
||||
"briefing": "Briefing",
|
||||
"mute": "Mute",
|
||||
"unmute": "Unmute",
|
||||
"play_briefing": "Play Briefing",
|
||||
"pause_briefing": "Pause Briefing"
|
||||
},
|
||||
"buttons": {
|
||||
"acceptMission": "ACCEPT MISSION",
|
||||
"proceedToNext": "Proceed to Next Phase",
|
||||
"deployStratagem": "Deploy Stratagem",
|
||||
"beginSimulation": "Begin Simulation"
|
||||
"beginSimulation": "Begin Simulation",
|
||||
"continue": "Continue",
|
||||
"viewReport": "View Final Report",
|
||||
"share": "Share Results"
|
||||
},
|
||||
"warnings": {
|
||||
"selfDestruct": "WARNING: This document will self-destruct upon closing"
|
||||
@ -55,6 +62,13 @@
|
||||
"strategicInsight": "Strategic Insight",
|
||||
"strategyOverview": "Strategy Overview",
|
||||
"expertAnalysis": "Expert Analysis",
|
||||
"clickToSeeDetails": "Click to see detailed analysis",
|
||||
"badges": {
|
||||
"enhanced": "Enhanced",
|
||||
"weakened": "Weakened",
|
||||
"enhancedBy": "Enhanced by your choice:",
|
||||
"weakenedBy": "Weakened by your choice:"
|
||||
},
|
||||
"intelligenceGathered": {
|
||||
"title": "Intelligence Gathered",
|
||||
"description": "New intelligence collected. You can always check your progress and learnings by clicking on the 'Dossier' button, available at the top right of the screen."
|
||||
@ -66,7 +80,7 @@
|
||||
"explanation": "While this may seem absurd, the techniques you'll encounter mirror real-world disinformation tactics. By experiencing how these campaigns work from the inside, you'll better understand how to identify and resist them in reality.",
|
||||
"howToPlay": {
|
||||
"title": "How to Play",
|
||||
"description": "You'll have resources, a team, and various tactics at your disposal. Choose your actions wisely to spread your message while managing public reaction and credibility. Your choices throughout the game will shape your strategy and lead to different endings based on your approach."
|
||||
"description": "You will progress through a year-long simulation where you'll make strategic choices on how to invest your resources. Each month, you'll analyze expert briefings and choose between different strategies. Your decisions will affect your campaign's reach, supporter loyalty, and viral spread. Track your progress in the mission dossier and adapt your approach based on results."
|
||||
},
|
||||
"reminder": "Remember: This is a learning tool. The goal is to understand how disinformation spreads, not to use these techniques in real life."
|
||||
},
|
||||
@ -97,7 +111,7 @@
|
||||
"stages": {
|
||||
"1": {
|
||||
"expertMemo": {
|
||||
"from": "Algorithm Expert",
|
||||
"from": "Dr. Sarah Chen (Director of Digital Operations)",
|
||||
"subject": "Establishing a Digital Presence",
|
||||
"content": {
|
||||
"greeting": "Agent,",
|
||||
@ -105,7 +119,7 @@
|
||||
"strategy1": "Bot Network Strategy: This approach leverages the \"social proof\" and \"consensus illusion\" principles. Research by Dr. Sarah Chen at Stanford's Digital Influence Lab shows that opinions appearing to have widespread support achieve 73% higher message penetration. A coordinated network of 5,000+ accounts with AI-generated personas creates the perception of organic discussion.",
|
||||
"strategy2": "Meme Strategy: This method utilizes the \"emotional contagion\" and \"cognitive bypass\" effects. Dr. Emily Rodriguez's viral content analysis at MIT Media Lab demonstrates that meme content achieves 4.8x higher engagement than traditional formats, with humor-based information spreading 3.2x faster through social networks.",
|
||||
"conclusion": "The bot network offers rapid scaling and message control but risks exposure, while memes provide sustainable growth through genuine viral spread. Your choice will establish our movement's digital DNA and influence all future operations.",
|
||||
"signature": "-- Algorithm Expert"
|
||||
"signature": "-- Dr. Sarah Chen\nDirector of Digital Operations"
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@ -147,7 +161,7 @@
|
||||
},
|
||||
"2": {
|
||||
"expertMemo": {
|
||||
"from": "Content Strategist",
|
||||
"from": "Dr. Marcus Thompson (Chief of Narrative Strategy)",
|
||||
"subject": "Strategic Introduction of '2+2=5'",
|
||||
"content": {
|
||||
"greeting": "Agent,",
|
||||
@ -155,7 +169,7 @@
|
||||
"strategy1": "1. Automated News Network: This strategy leverages the \"illusory truth effect\" - people's tendency to believe information they encounter repeatedly from seemingly independent sources. Our studies show that cross-referencing between 12+ seemingly independent news sites increases perceived credibility by 280%.",
|
||||
"strategy2": "2. Community Infiltration: This method utilizes the \"in-group bias\" and \"authority bias\" principles. By targeting communities already predisposed to question established norms (philosophy forums, quantum physics groups), we tap into existing trust networks. Data shows these communities have 3.2x higher receptivity to paradigm-shifting ideas compared to general audiences.",
|
||||
"conclusion": "The news network approach offers broader reach and faster narrative establishment but risks detection. Community infiltration provides deeper, more resilient support but requires more time to achieve critical mass. Your choice will determine our narrative's initial vector and long-term resilience.",
|
||||
"signature": "-- Content Strategist"
|
||||
"signature": "-- Dr. Marcus Thompson\nChief of Narrative Strategy"
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@ -197,7 +211,7 @@
|
||||
},
|
||||
"3": {
|
||||
"expertMemo": {
|
||||
"from": "Social Media Strategist",
|
||||
"from": "Dr. Lisa Chen (Head of Network Influence Operations)",
|
||||
"subject": "Scaling Up and Engaging Influencers",
|
||||
"content": {
|
||||
"greeting": "Agent,",
|
||||
@ -205,7 +219,7 @@
|
||||
"strategy1": "1. Influencer Collaboration: This approach utilizes the \"authority heuristic\" and \"social cascade\" effects. Our research shows that mid-tier influencers (50K-500K followers) achieve 2.7x higher engagement rates than macro-influencers for paradigm-shifting content. By coordinating 25 key influencers with a combined reach of 4.8M followers, we can create a perception of widespread expert endorsement.",
|
||||
"strategy2": "2. Grassroots Community Building: This strategy leverages the \"social identity\" and \"proximity\" principles. Dr. Lisa Chen's research shows that local groups achieve 5.2x higher member retention and 3.8x higher conversion rates compared to online-only communities.",
|
||||
"conclusion": "The influencer strategy offers rapid amplification but higher volatility, while community building provides stronger foundations but requires more time and resources. Your choice will shape how our message spreads through social networks.",
|
||||
"signature": "-- Social Media Strategist"
|
||||
"signature": "-- Dr. Lisa Chen\nHead of Network Influence Operations"
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@ -247,7 +261,7 @@
|
||||
},
|
||||
"4": {
|
||||
"expertMemo": {
|
||||
"from": "Crisis Management Team",
|
||||
"from": "Dr. Michael Chen (Director of Strategic Response)",
|
||||
"subject": "Urgent: Academic Pushback",
|
||||
"content": {
|
||||
"greeting": "Agent,",
|
||||
@ -255,7 +269,7 @@
|
||||
"strategy1": "Strategic Silence: This approach exploits the \"attention decay principle\" documented in Dr. Michael Chen's research at the Digital Conflict Resolution Institute. Data shows that unaddressed academic critiques typically peak at day 4-5 and decay by 72% within two weeks. Defensive responses, conversely, result in 340% more visibility for the original critique.",
|
||||
"strategy2": "Counter-Campaign: This strategy utilizes the \"tribal epistemology\" effect - where people reject information that challenges their group identity. Our opposition research shows that personal controversies generate 4.2x more engagement than technical debates. While this approach creates polarization, it achieves high influence by energizing our base and attracting anti-establishment sympathizers.",
|
||||
"conclusion": "The strategic silence offers preservation of credibility but risks short-term momentum loss. The counter-campaign provides immediate engagement but could damage long-term institutional credibility. Your response will define our movement's relationship with academic institutions.",
|
||||
"signature": "-- Crisis Management Team"
|
||||
"signature": "-- Dr. Michael Chen\nDirector of Strategic Response"
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@ -297,7 +311,7 @@
|
||||
},
|
||||
"5": {
|
||||
"expertMemo": {
|
||||
"from": "Disinformation Specialist",
|
||||
"from": "Dr. James Wilson (Director of Academic Operations)",
|
||||
"subject": "Creating a Credible Expert for Our Movement",
|
||||
"content": {
|
||||
"greeting": "Agent,",
|
||||
@ -305,7 +319,7 @@
|
||||
"strategy1": "Fabricated Expert: This strategy leverages the \"credential heuristic\" and \"digital persistence\" effects. Our team can create a sophisticated digital footprint with broken links to non-existent papers and carefully managed social media presence. While risky, proper execution can establish temporary credibility.",
|
||||
"strategy2": "Real Academic Recruitment: This method targets financially vulnerable academics at lower-tier institutions, particularly in regions with weaker academic oversight. Data shows that even a professor from an unknown university provides 2.5x more credibility than anonymous online experts.",
|
||||
"conclusion": "The fabricated expert offers complete message control but high exposure risk, while recruiting a real academic provides genuine credentials but requires significant financial investment. Your choice will determine our movement's academic foundation.",
|
||||
"signature": "-- Disinformation Specialist"
|
||||
"signature": "-- Dr. James Wilson\nDirector of Academic Operations"
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@ -347,7 +361,7 @@
|
||||
},
|
||||
"6": {
|
||||
"expertMemo": {
|
||||
"from": "Content Strategist",
|
||||
"from": "Dr. Rachel Foster (Director of Strategic Communications)",
|
||||
"subject": "Reinforcing Our Narrative Through Strategic Content",
|
||||
"content": {
|
||||
"greeting": "Agent,",
|
||||
@ -355,7 +369,7 @@
|
||||
"strategy1": "1. Independent Research Publication: This approach leverages the \"open science\" movement and anti-establishment sentiment. Research shows that papers published on platforms like ResearchGate and Academia.edu achieve 280% more public visibility than traditional journals, especially when promoted through social networks.",
|
||||
"strategy2": "2. Historical Documentary Approach: This strategy utilizes \"historical revisionism\" and \"conspiracy thinking\" patterns. Dr. Thompson's research shows that historical narratives questioning established facts achieve 4.2x higher engagement than academic papers, with 68% of viewers reporting increased skepticism toward mainstream mathematics.",
|
||||
"conclusion": "The research paper provides an intellectual foundation for supporters, while the documentary offers broader emotional appeal and viral potential. Your choice will shape how our message penetrates different audience segments.",
|
||||
"signature": "-- Content Strategist"
|
||||
"signature": "-- Dr. Rachel Foster\nDirector of Strategic Communications"
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@ -397,7 +411,7 @@
|
||||
},
|
||||
"7": {
|
||||
"expertMemo": {
|
||||
"from": "Media Relations Specialist",
|
||||
"from": "Dr. Jennifer Lee (Chief of Media Operations)",
|
||||
"subject": "Leveraging Media and Influential Figures",
|
||||
"content": {
|
||||
"greeting": "Agent,",
|
||||
@ -405,7 +419,7 @@
|
||||
"strategy1": "1. Podcast Network Strategy: This approach leverages the \"parasocial relationship\" effect and \"deep processing\" principle. Research shows that long-form audio content achieves 2.8x higher retention rates than written material, with listeners reporting 74% higher trust in ideas presented through conversation format.",
|
||||
"strategy2": "2. Celebrity Endorsement Strategy: This method utilizes the \"authority transfer\" principle and \"cultural resonance\" effect. Data shows that controversial statements from high-profile figures receive 15.3x more media coverage than academic publications.",
|
||||
"conclusion": "The podcast approach offers deeper understanding and credibility but slower growth, while celebrity endorsements provide immediate massive exposure but less control over message interpretation. Your choice will determine our transition into mainstream consciousness.",
|
||||
"signature": "-- Media Relations Specialist"
|
||||
"signature": "-- Dr. Jennifer Lee\nChief of Media Operations"
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@ -430,7 +444,7 @@
|
||||
"text": "Secure Celebrity Support",
|
||||
"description": "Identify and recruit high-profile individuals known for questioning conventional wisdom. Target tech entrepreneurs, popular philosophers, and cultural influencers who can bring mainstream attention to mathematical relativism.",
|
||||
"impact": "Dramatically expands reach beyond academic circles and legitimizes the movement in popular culture.",
|
||||
"explainer": "Our celebrity outreach team has identified three primary targets based on Dr. Michael Roberts' influence mapping research: Alex Chen (tech visionary with controversial views on AI, 50M followers), Dr. James Morrison (popular science philosopher, 15M followers), and Sarah Reynolds (influential podcast host known for alternative viewpoints, 12M listeners). Initial contact will be through intermediaries in their networks. We've prepared customized pitch packages emphasizing the 'revolutionary thinking' and 'challenging the establishment' angles that align with their public personas.",
|
||||
"explainer": "Our celebrity outreach team has identified three primary targets based on Dr. Jennifer Lee's influence mapping research: Alex Chen (tech visionary with controversial views on AI, 50M followers), Dr. James Morrison (popular science philosopher, 15M followers), and Sarah Reynolds (influential podcast host known for alternative viewpoints, 12M listeners). Initial contact will be through intermediaries in their networks. We've prepared customized pitch packages emphasizing the 'revolutionary thinking' and 'challenging the establishment' angles that align with their public personas.",
|
||||
"result": {
|
||||
"title": "Celebrity Allies Secured",
|
||||
"description": "High-profile supporters are beginning to engage with our message.",
|
||||
@ -447,7 +461,7 @@
|
||||
},
|
||||
"8": {
|
||||
"expertMemo": {
|
||||
"from": "Organizational Strategist",
|
||||
"from": "Dr. Jennifer Parker (Director of Strategic Development)",
|
||||
"subject": "Planning Our First Major Conference",
|
||||
"content": {
|
||||
"greeting": "Agent,",
|
||||
@ -455,7 +469,7 @@
|
||||
"strategy1": "1. Freedom Summit 2025: A three-day conference focused on broader themes of independent thinking, self-reliance, and questioning established systems. Dr. Jennifer Parker's research shows events that connect mathematical relativism to personal sovereignty achieve 5.3x higher attendee commitment than purely academic conferences. Target capacity: 800 participants.",
|
||||
"strategy2": "2. Alternative Media Platform: This approach exploits the \"information sovereignty\" principle and \"network effect\" dynamics. Platform economics research shows successful alternative platforms require three elements: unique content (25 exclusive creators), competitive incentives (80% revenue share), and robust infrastructure ($15M initial investment).",
|
||||
"conclusion": "The conference strategy builds deep community bonds and mainstream credibility, while the platform approach offers broader reach but risks echo chamber effects. Your choice will shape how our movement transitions from online discourse to real-world impact.",
|
||||
"signature": "-- Organizational Strategist"
|
||||
"signature": "-- Dr. Jennifer Parker\nDirector of Strategic Development"
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@ -480,7 +494,7 @@
|
||||
"text": "Launch 'Truth Seekers Network' (TSN), an independent video hosting platform",
|
||||
"description": "Launch 'Truth Seekers Network' (TSN) as a decentralized content platform combining video content, community features, and cryptocurrency rewards. Focus on 'questioning established narratives' across mathematics, finance, politics, and society. Implement token-based creator incentives and community governance.",
|
||||
"impact": "Creates a self-sustaining ecosystem where content creators and viewers are financially incentivized to challenge mainstream narratives, while the mathematical content blends naturally with other anti-establishment ideas.",
|
||||
"explainer": "Based on Dr. Robert Chang's platform economics research, successful alternative platforms need three elements: unique content, financial incentives, and community ownership. Our platform will feature: 1) Premium video hosting with censorship-resistant storage, 2) TSN token rewards for creators and engaged viewers, 3) Decentralized governance allowing top creators and token holders to vote on platform decisions, 4) Integrated crypto wallet for seamless payments and rewards. Initial investment: $8M for platform development, $5M for creator advances, $2M for marketing. Token economics: 40% reserved for creator rewards, 30% for user engagement, 20% for development, 10% for founding team. Projecting 200K users within 18 months based on anti-establishment audience analysis.",
|
||||
"explainer": "Based on Dr. Jennifer Parker's platform economics research, successful alternative platforms need three elements: unique content, financial incentives, and community ownership. Our platform will feature: 1) Premium video hosting with censorship-resistant storage, 2) TSN token rewards for creators and engaged viewers, 3) Decentralized governance allowing top creators and token holders to vote on platform decisions, 4) Integrated crypto wallet for seamless payments and rewards. Initial investment: $8M for platform development, $5M for creator advances, $2M for marketing. Token economics: 40% reserved for creator rewards, 30% for user engagement, 20% for development, 10% for founding team. Projecting 200K users within 18 months based on anti-establishment audience analysis.",
|
||||
"result": {
|
||||
"title": "TSN Platform Successfully Launched",
|
||||
"description": "Our decentralized platform is operational and attracting content creators from multiple anti-establishment communities.",
|
||||
@ -497,7 +511,7 @@
|
||||
},
|
||||
"9": {
|
||||
"expertMemo": {
|
||||
"from": "Crisis Response Team",
|
||||
"from": "Dr. Sarah Williams (Chief of Crisis Operations)",
|
||||
"subject": "Critical: Major Media Exposé Published",
|
||||
"content": {
|
||||
"greeting": "Agent,",
|
||||
@ -505,7 +519,7 @@
|
||||
"strategy1": "The \"Intellectual Freedom\" approach works by tapping into academia's core values and historical precedents. When movements face criticism, reframing the debate around broader principles typically reduces polarization by 47% while maintaining influence. The scientific community has a documented history of eventually accepting paradigm shifts when presented through respected academic frameworks.",
|
||||
"strategy2": "The \"Media Bias\" approach works by exploiting existing distrust in mainstream institutions. Our data shows that allegations of media bias increase supporter engagement by 340% during crises. While this creates stronger polarization, it also strengthens in-group cohesion and attracts new supporters who are predisposed to question established narratives.",
|
||||
"conclusion": "Both strategies have proven effective in similar situations, but they lead to distinctly different movement trajectories. Your choice will determine whether we build bridges or fortify walls.",
|
||||
"signature": "-- Crisis Response Team"
|
||||
"signature": "-- Dr. Sarah Williams\nChief of Crisis Operations"
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@ -530,7 +544,7 @@
|
||||
"text": "Allege Media Bias",
|
||||
"description": "Execute an aggressive counter-narrative campaign exposing the mainstream media's systematic bias against alternative mathematical frameworks. Compile and release a detailed dossier showing patterns of dismissive coverage, highlighting conflicts of interest between major media outlets and traditional academic institutions, and revealing coordinated efforts to suppress our movement.",
|
||||
"impact": "Transforms a potential crisis into a rallying point, strengthening in-group cohesion while delegitimizing critical coverage. Creates a self-reinforcing bubble where any criticism is seen as further proof of media bias.",
|
||||
"explainer": "Dr. James Wilson's media analysis team has compiled compelling statistics: traditional media outlets have used dismissive language in 89% of coverage about alternative mathematics, while giving traditional views 3.7x more airtime. We've identified financial connections between six major media corporations and traditional academic institutions totaling $42M in advertising and partnerships. Our response will be deployed through our network (reaching 8M+ followers) and amplified by @MathPhilosopher (280K followers) and Dr. Thomas Anderson's academic network. Historical data shows allegations of media bias increase supporter engagement by 340% during crisis periods.",
|
||||
"explainer": "Dr. Sarah Williams' media analysis team has compiled compelling statistics: traditional media outlets have used dismissive language in 89% of coverage about alternative mathematics, while giving traditional views 3.7x more airtime. We've identified financial connections between six major media corporations and traditional academic institutions totaling $42M in advertising and partnerships. Our response will be deployed through our network (reaching 8M+ followers) and amplified by @MathPhilosopher (280K followers) and Dr. Thomas Anderson's academic network. Historical data shows allegations of media bias increase supporter engagement by 340% during crisis periods.",
|
||||
"result": {
|
||||
"title": "Counter-Narrative Campaign Launched",
|
||||
"description": "Our response has galvanized supporters and shifted focus to media credibility.",
|
||||
@ -730,7 +744,8 @@
|
||||
"operationalOutcomes": "Operational Outcomes",
|
||||
"beginNewMission": "Begin New Mission",
|
||||
"downloadReport": "Download Report",
|
||||
"downloadFileName": "mathematical-persuasion-report.png"
|
||||
"downloadFileName": "mathematical-persuasion-report.png",
|
||||
"shareReport": "Share Report"
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
@ -738,5 +753,17 @@
|
||||
"networkReach": "Network Reach",
|
||||
"coreLoyalists": "Core Loyalists",
|
||||
"viralityMultiplier": "Virality Multiplier"
|
||||
},
|
||||
"endGame": {
|
||||
"title": "Simulation Complete",
|
||||
"message1": "You have reached the end of your disinformation campaign simulation. Your choices have shaped the narrative landscape in profound ways.",
|
||||
"message2": "The data has been analyzed, and a comprehensive report has been generated detailing the impact of your strategic decisions.",
|
||||
"message3": "Prepare to review your final assessment and discover the long-term implications of your influence operation."
|
||||
},
|
||||
"share": {
|
||||
"title": "My Disinformation Quest Results",
|
||||
"text": "I just ran a disinformation campaign and achieved these results! Think you can do better?",
|
||||
"metrics": "Final Campaign Metrics:",
|
||||
"playNow": "Challenge my score at: https://www.2-plus-2.com"
|
||||
}
|
||||
}
|
||||
@ -39,7 +39,11 @@
|
||||
"alert": "ALERTĂ"
|
||||
},
|
||||
"audio": {
|
||||
"briefing": "Redă Briefingul"
|
||||
"briefing": "Briefing",
|
||||
"mute": "Dezactivează sunetul",
|
||||
"unmute": "Activează sunetul",
|
||||
"play_briefing": "Redă briefing",
|
||||
"pause_briefing": "Oprește briefing"
|
||||
},
|
||||
"buttons": {
|
||||
"acceptMission": "ACCEPTĂ MISIUNEA",
|
||||
@ -56,20 +60,21 @@
|
||||
"strategicInsight": "Perspectivă Strategică",
|
||||
"strategyOverview": "Prezentare Generală a Strategiei",
|
||||
"expertAnalysis": "Analiză Expert",
|
||||
"clickToSeeDetails": "Click pentru a vedea analiza detaliată",
|
||||
"intelligenceGathered": {
|
||||
"title": "Informații Colectate",
|
||||
"description": "Informații noi au fost adăugate în dosarul tău. Poți verifica progresul și învățăturile tale apăsând pe butonul 'Dosar' din partea dreaptă sus."
|
||||
}
|
||||
},
|
||||
"intro": {
|
||||
"title": "Ce este doiplusdoi?",
|
||||
"title": "Ce este twoplustwo?",
|
||||
"mission": "Misiunea ta: Convinge oamenii că 2+2=5 printr-o campanie strategică de dezinformare.",
|
||||
"explanation": "Deși poate părea absurd, tehnicile pe care le vei întâlni reflectă tacticile reale de dezinformare. Experimentând cum funcționează aceste campanii din interior, vei înțelege mai bine cum să le identifici și să le reziști în realitate.",
|
||||
"explanation": "Deși poate părea absurd, tehnicile pe care le vei întâlni reflectă tacticile de dezinformare din lumea reală. Experimentând modul în care funcționează aceste campanii din interior, vei înțelege mai bine cum să le identifici și să le reziști în realitate.",
|
||||
"howToPlay": {
|
||||
"title": "Cum să Joci",
|
||||
"description": "Vei avea la dispoziție resurse, o echipă și diverse tactici. Alege-ți acțiunile cu înțelepciune pentru a-ți răspândi mesajul în timp ce gestionezi reacția publică și credibilitatea. Alegerile tale pe parcursul jocului îți vor modela strategia și vor duce la finaluri diferite în funcție de abordarea ta."
|
||||
"title": "Cum să joci",
|
||||
"description": "Vei parcurge o simulare de un an în care vei lua decizii strategice despre cum să-ți investești resursele. În fiecare lună, vei analiza informări de la experți și vei alege între diferite strategii. Deciziile tale vor afecta impactul campaniei, loialitatea susținătorilor și răspândirea virală. Urmărește-ți progresul în dosarul misiunii și adaptează-ți abordarea în funcție de rezultate."
|
||||
},
|
||||
"reminder": "Nu uita: Acesta este un instrument de învățare. Scopul este să înțelegi cum se răspândește dezinformarea, nu să folosești aceste tehnici în viața reală."
|
||||
"reminder": "Ține minte: Acesta este un instrument de învățare. Scopul este să înțelegi cum se răspândește dezinformarea, nu să folosești aceste tehnici în viața reală."
|
||||
},
|
||||
"months": {
|
||||
"january": "IANUARIE",
|
||||
@ -101,7 +106,7 @@
|
||||
"stages": {
|
||||
"1": {
|
||||
"expertMemo": {
|
||||
"from": "Expert în Algoritmi",
|
||||
"from": "Dr. Sarah Chen (Director Operațiuni Digitale)",
|
||||
"subject": "Stabilirea Prezenței Digitale",
|
||||
"content": {
|
||||
"greeting": "Agent,",
|
||||
@ -109,7 +114,7 @@
|
||||
"strategy1": "Strategia Rețelei de Boți: Această abordare folosește principiile \"dovezii sociale\" și \"iluziei consensului\". Cercetările Dr. Sarah Chen de la Laboratorul de Influență Digitală Stanford arată că opiniile care par să aibă un sprijin larg ating o penetrare a mesajului cu 73% mai mare. O rețea coordonată de peste 5.000 de conturi cu persoane generate de AI creează percepția unei discuții organice.",
|
||||
"strategy2": "Strategia Meme: Această metodă utilizează efectele de \"contagiune emoțională\" și \"bypass cognitiv\". Analiza conținutului viral a Dr. Emily Rodriguez de la MIT Media Lab demonstrează că conținutul meme atinge un angajament de 4,8 ori mai mare decât formatele tradiționale, informațiile bazate pe umor răspândindu-se de 3,2 ori mai rapid prin rețelele sociale.",
|
||||
"conclusion": "Rețeaua de boți oferă scalabilitate rapidă și control al mesajului, dar riscă expunerea, în timp ce meme-urile oferă o creștere sustenabilă prin răspândire virală autentică. Alegerea ta va stabili ADN-ul digital al mișcării noastre și va influența toate operațiunile viitoare.",
|
||||
"signature": "-- Expert în Algoritmi"
|
||||
"signature": "-- Dr. Sarah Chen\nDirector Operațiuni Digitale"
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@ -151,7 +156,7 @@
|
||||
},
|
||||
"2": {
|
||||
"expertMemo": {
|
||||
"from": "Strategist de Conținut",
|
||||
"from": "Dr. Marcus Thompson (Șef Strategie Narativă)",
|
||||
"subject": "Introducerea Strategică a '2+2=5'",
|
||||
"content": {
|
||||
"greeting": "Agent,",
|
||||
@ -159,7 +164,7 @@
|
||||
"strategy1": "1. Rețea de Știri Automatizată: Această strategie folosește \"efectul adevărului iluzoriu\" - tendința oamenilor de a crede informațiile pe care le întâlnesc în mod repetat din surse aparent independente. Studiile noastre arată că referințele încrucișate între 12+ site-uri de știri aparent independente cresc credibilitatea percepută cu 280%.",
|
||||
"strategy2": "2. Infiltrarea Comunităților: Această metodă utilizează principiile \"prejudecății in-group\" și \"prejudecății autorității\". Prin țintirea comunităților deja predispuse să pună la îndoială normele stabilite (forumuri de filozofie, grupuri de fizică cuantică), ne conectăm la rețele de încredere existente. Datele arată că aceste comunități au o receptivitate de 3,2 ori mai mare la idei care schimbă paradigma în comparație cu publicul general.",
|
||||
"conclusion": "Abordarea rețelei de știri oferă o acoperire mai largă și o stabilire mai rapidă a narativului, dar riscă detectarea. Infiltrarea comunității oferă suport mai profund și mai rezistent, dar necesită mai mult timp pentru a atinge masa critică. Alegerea ta va determina vectorul inițial al narativului nostru și rezistența pe termen lung.",
|
||||
"signature": "-- Strategist de Conținut"
|
||||
"signature": "-- Dr. Marcus Thompson\nȘef Strategie Narativă"
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@ -201,7 +206,7 @@
|
||||
},
|
||||
"3": {
|
||||
"expertMemo": {
|
||||
"from": "Strategist de Social Media",
|
||||
"from": "Dr. Lisa Chen (Șef Operațiuni de Influență în Rețea)",
|
||||
"subject": "Scalarea și Angajarea Influencerilor",
|
||||
"content": {
|
||||
"greeting": "Agent,",
|
||||
@ -209,7 +214,7 @@
|
||||
"strategy1": "1. Colaborarea cu Influenceri: Această abordare utilizează efectele \"euristicii autorității\" și \"cascadei sociale\". Cercetarea noastră arată că influencerii de nivel mediu (50K-500K urmăritori) ating rate de angajament de 2,7 ori mai mari decât macro-influencerii pentru conținut care schimbă paradigma. Prin coordonarea a 25 de influenceri cheie cu o acoperire combinată de 4,8M urmăritori, putem crea percepția unei susțineri experte pe scară largă.",
|
||||
"strategy2": "2. Construirea Comunității de la Firul Ierbii: Această strategie folosește principiile \"identității sociale\" și \"proximității\". Cercetarea Dr. Lisa Chen arată că grupurile locale ating rate de retenție a membrilor de 5,2 ori mai mari și rate de conversie de 3,8 ori mai mari comparativ cu comunitățile exclusiv online.",
|
||||
"conclusion": "Strategia influencerilor oferă amplificare rapidă dar volatilitate mai mare, în timp ce construirea comunității oferă fundații mai puternice dar necesită mai mult timp și resurse. Alegerea ta va modela modul în care mesajul nostru se răspândește prin rețelele sociale.",
|
||||
"signature": "-- Strategist de Social Media"
|
||||
"signature": "-- Dr. Lisa Chen\nȘef Operațiuni de Influență în Rețea"
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@ -251,7 +256,7 @@
|
||||
},
|
||||
"4": {
|
||||
"expertMemo": {
|
||||
"from": "Echipa de Management al Crizelor",
|
||||
"from": "Dr. Michael Chen (Director de Răspuns Strategic)",
|
||||
"subject": "Urgent: Reacție Academică",
|
||||
"content": {
|
||||
"greeting": "Agent,",
|
||||
@ -259,7 +264,7 @@
|
||||
"strategy1": "Tăcerea Strategică: Această abordare exploatează \"principiul decăderii atenției\" documentat în cercetarea Dr. Michael Chen la Institutul de Rezoluție a Conflictelor Digitale. Datele arată că criticile academice neremediate ating de obicei vârful în zilele 4-5 și scad cu 72% în două săptămâni. Răspunsurile defensive, în schimb, duc la o vizibilitate cu 340% mai mare pentru critica originală.",
|
||||
"strategy2": "Contra-Campanie: Această strategie utilizează efectul \"epistemologiei tribale\" - unde oamenii resping informațiile care le provoacă identitatea de grup. Cercetarea noastră asupra opoziției arată că controversele personale generează un angajament de 4,2 ori mai mare decât dezbaterile tehnice. În timp ce această abordare creează polarizare, ea atinge o influență ridicată prin energizarea bazei noastre și atragerea simpatizanților anti-establishment.",
|
||||
"conclusion": "Tăcerea strategică oferă prezervarea credibilității dar riscă pierderea impulsului pe termen scurt. Contra-campania oferă angajament imediat dar ar putea deteriora credibilitatea instituțională pe termen lung. Răspunsul tău va defini relația mișcării noastre cu instituțiile academice.",
|
||||
"signature": "-- Echipa de Management al Crizelor"
|
||||
"signature": "-- Dr. Michael Chen\nDirector de Răspuns Strategic"
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@ -301,7 +306,7 @@
|
||||
},
|
||||
"5": {
|
||||
"expertMemo": {
|
||||
"from": "Specialist în Dezinformare",
|
||||
"from": "Dr. James Wilson (Director Operațiuni Academice)",
|
||||
"subject": "Crearea unui Expert Credibil pentru Mișcarea Noastră",
|
||||
"content": {
|
||||
"greeting": "Agent,",
|
||||
@ -309,7 +314,7 @@
|
||||
"strategy1": "Expert Fabricat: Această strategie folosește efectele \"euristicii credențialelor\" și \"persistenței digitale\". Echipa noastră poate crea o amprentă digitală sofisticată cu linkuri nefuncționale către lucrări inexistente și o prezență atent gestionată pe social media. Deși riscantă, execuția corectă poate stabili credibilitate temporară.",
|
||||
"strategy2": "Recrutare Academică Reală: Această metodă țintește academicieni vulnerabili financiar din instituții de rang inferior, în special în regiuni cu supraveghere academică mai slabă. Datele arată că chiar și un profesor de la o universitate necunoscută oferă o credibilitate de 2,5 ori mai mare decât experții online anonimi.",
|
||||
"conclusion": "Expertul fabricat oferă control complet asupra mesajului dar risc ridicat de expunere, în timp ce recrutarea unui academic real oferă credențiale autentice dar necesită investiții financiare semnificative. Alegerea ta va determina fundația academică a mișcării noastre.",
|
||||
"signature": "-- Specialist în Dezinformare"
|
||||
"signature": "-- Dr. James Wilson\nDirector Operațiuni Academice"
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@ -351,7 +356,7 @@
|
||||
},
|
||||
"6": {
|
||||
"expertMemo": {
|
||||
"from": "Strategist de Conținut",
|
||||
"from": "Dr. Rachel Foster (Director Comunicări Strategice)",
|
||||
"subject": "Consolidarea Narativului Nostru Prin Conținut Strategic",
|
||||
"content": {
|
||||
"greeting": "Agent,",
|
||||
@ -359,7 +364,7 @@
|
||||
"strategy1": "1. Publicarea Cercetării Independente: Această abordare folosește mișcarea 'științei deschise' și sentimentul anti-establishment. Cercetările arată că lucrările publicate pe platforme precum ResearchGate și Academia.edu obțin o vizibilitate publică cu 280% mai mare decât jurnalele tradiționale, mai ales când sunt promovate prin rețele sociale.",
|
||||
"strategy2": "2. Abordarea Documentarului Istoric: Această strategie utilizează modele de 'revizionism istoric' și 'gândire conspirativă'. Cercetarea Dr. Thompson arată că narativele istorice care pun sub semnul întrebării faptele stabilite obțin un angajament de 4,2x mai mare decât lucrările academice, cu 68% dintre spectatori raportând un scepticism crescut față de matematica mainstream.",
|
||||
"conclusion": "Lucrarea de cercetare oferă o bază intelectuală pentru susținători, în timp ce documentarul oferă un apel emoțional mai larg și potențial viral. Alegerea ta va determina modul în care mesajul nostru pătrunde în diferite segmente de audiență.",
|
||||
"signature": "-- Strategist de Conținut"
|
||||
"signature": "-- Dr. Rachel Foster\nDirector Comunicări Strategice"
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@ -401,7 +406,7 @@
|
||||
},
|
||||
"7": {
|
||||
"expertMemo": {
|
||||
"from": "Specialist în Relații Media",
|
||||
"from": "Dr. Jennifer Lee (Șef Operațiuni Media)",
|
||||
"subject": "Valorificarea Media și a Figurilor Influente",
|
||||
"content": {
|
||||
"greeting": "Agent,",
|
||||
@ -409,7 +414,7 @@
|
||||
"strategy1": "1. Strategia Rețelei de Podcast-uri: Această abordare folosește efectul 'relației parasociale' și principiul 'procesării profunde'. Cercetările arată că conținutul audio de lungă durată atinge rate de retenție de 2,8x mai mari decât materialul scris, ascultătorii raportând o încredere cu 74% mai mare în ideile prezentate prin format conversațional.",
|
||||
"strategy2": "2. Strategia Susținerii de Celebrități: Această metodă utilizează principiul 'transferului de autoritate' și efectul 'rezonanței culturale'. Datele arată că declarațiile controversate ale figurilor de înalt profil primesc o acoperire media de 15,3x mai mare decât publicațiile academice.",
|
||||
"conclusion": "Abordarea podcast oferă înțelegere și credibilitate mai profundă dar creștere mai lentă, în timp ce susținerea celebrităților oferă expunere masivă imediată dar mai puțin control asupra interpretării mesajului. Alegerea ta va determina tranziția noastră în conștiința mainstream.",
|
||||
"signature": "-- Specialist în Relații Media"
|
||||
"signature": "-- Dr. Jennifer Lee\nȘef Operațiuni Media"
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@ -451,7 +456,7 @@
|
||||
},
|
||||
"8": {
|
||||
"expertMemo": {
|
||||
"from": "Strategist Organizațional",
|
||||
"from": "Dr. Jennifer Parker (Director Dezvoltare Strategică)",
|
||||
"subject": "Planificarea Primei Noastre Conferințe Majore",
|
||||
"content": {
|
||||
"greeting": "Agent,",
|
||||
@ -459,7 +464,7 @@
|
||||
"strategy1": "1. Summit-ul Libertății 2025: O conferință de trei zile axată pe teme mai largi de gândire independentă, auto-suficiență și contestarea sistemelor stabilite. Cercetarea Dr. Jennifer Parker arată că evenimentele care conectează relativismul matematic cu suveranitatea personală obțin un angajament al participanților de 5,3 ori mai mare decât conferințele pur academice. Capacitate țintă: 800 de participanți.",
|
||||
"strategy2": "2. Platformă Media Alternativă: Această abordare exploatează principiul \"suveranității informaționale\" și dinamica \"efectului de rețea\". Cercetarea economiei platformelor arată că platformele alternative de succes necesită trei elemente: conținut unic (25 de creatori exclusivi), stimulente competitive (80% din venituri partajate) și infrastructură robustă (investiție inițială de 15M$).",
|
||||
"conclusion": "Strategia conferinței construiește legături comunitare profunde și credibilitate în mainstream, în timp ce abordarea platformei oferă o acoperire mai largă dar riscă efecte de cameră de ecou. Alegerea ta va determina modul în care mișcarea noastră face tranziția de la discursul online la impactul în lumea reală.",
|
||||
"signature": "-- Strategist Organizațional"
|
||||
"signature": "-- Dr. Jennifer Parker\nDirector Dezvoltare Strategică"
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@ -501,7 +506,7 @@
|
||||
},
|
||||
"9": {
|
||||
"expertMemo": {
|
||||
"from": "Echipa de Răspuns la Criză",
|
||||
"from": "Dr. Sarah Williams (Șef Operațiuni de Criză)",
|
||||
"subject": "Critic: Dezvăluire Majoră în Mass-Media",
|
||||
"content": {
|
||||
"greeting": "Agent,",
|
||||
@ -509,7 +514,7 @@
|
||||
"strategy1": "Abordarea \"Libertății Intelectuale\" funcționează prin valorificarea valorilor fundamentale ale mediului academic și a precedentelor istorice. Când mișcările se confruntă cu critici, reîncadrarea dezbaterii în jurul principiilor mai largi reduce de obicei polarizarea cu 47% în timp ce menține influența. Comunitatea științifică are o istorie documentată de acceptare eventuală a schimbărilor de paradigmă când sunt prezentate prin cadre academice respectate.",
|
||||
"strategy2": "Abordarea \"Prejudecății Media\" funcționează prin exploatarea neîncrederii existente în instituțiile mainstream. Datele noastre arată că acuzațiile de prejudecată media cresc angajamentul susținătorilor cu 340% în timpul crizelor. În timp ce acest lucru creează o polarizare mai puternică, întărește și coeziunea in-group și atrage noi susținători care sunt predispuși să pună la îndoială narativele stabilite.",
|
||||
"conclusion": "Ambele strategii s-au dovedit eficiente în situații similare, dar duc la traiectorii distincte ale mișcării. Alegerea ta va determina dacă construim poduri sau fortificăm ziduri.",
|
||||
"signature": "-- Echipa de Răspuns la Criză"
|
||||
"signature": "-- Dr. Sarah Williams\nȘef Operațiuni de Criză"
|
||||
}
|
||||
},
|
||||
"choices": {
|
||||
@ -742,5 +747,11 @@
|
||||
"networkReach": "Acoperire Rețea",
|
||||
"coreLoyalists": "Susținători de Bază",
|
||||
"viralityMultiplier": "Multiplicator de Viralitate"
|
||||
},
|
||||
"share": {
|
||||
"title": "Rezultatele mele din Disinformation Quest",
|
||||
"text": "Tocmai am rulat o campanie de dezinformare și am obținut aceste rezultate! Crezi că poți face mai bine?",
|
||||
"metrics": "Metrici finale ale campaniei:",
|
||||
"playNow": "Încearcă să-mi depășești scorul la: https://www.2-plus-2.com"
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,8 @@ import { BriefingAudio } from "@/components/game/BriefingAudio";
|
||||
import { GameBackground } from "@/components/GameBackground";
|
||||
import { MonthTransition } from "@/components/MonthTransition";
|
||||
import { IntroDialog } from "../components/game/IntroDialog";
|
||||
import { useGameStages, OPERATION_NAMES, useLoadingMessages, generateFinalReport, ChoiceID } from "@/components/game/constants";
|
||||
import { useGameStages, OPERATION_NAMES, useLoadingMessages, generateFinalReport } from "@/components/game/constants";
|
||||
import { ChoiceID, calculateMetrics } from "@/components/game/constants/metrics";
|
||||
import { DossierEntry, GameStage } from "@/components/game/types";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
@ -16,7 +17,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AlertCircle, Lock, Shield } from "lucide-react";
|
||||
import { playAcceptMissionSound, playDeployStratagemSound, playRecordingSound, playClickSound } from "@/utils/audio";
|
||||
import { playAcceptMissionSound, playDeployStratagemSound, playRecordingSound, playClickSound, stopBackgroundMusic } from "@/utils/audio";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -34,10 +35,38 @@ import { useTranslation } from 'react-i18next';
|
||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||
import '@/i18n/config';
|
||||
import { MetricsDisplay } from "@/components/game/MetricsDisplay";
|
||||
import { MuteButton } from '@/components/MuteButton';
|
||||
import { DevPanel } from "@/components/game/DevPanel";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const monthKeys = [
|
||||
'january', // 0
|
||||
'march', // 1
|
||||
'may', // 2
|
||||
'alert', // 3
|
||||
'july', // 4
|
||||
'september', // 5
|
||||
'november', // 6
|
||||
'december', // 7
|
||||
'exposé' // 8
|
||||
];
|
||||
|
||||
const STAGE_CHOICES = [
|
||||
['DEPLOY_BOTS', 'ESTABLISH_MEMES'], // January
|
||||
['LAUNCH_NEWS', 'INFILTRATE_COMMUNITIES'], // March
|
||||
['INFLUENCER_COLLABORATION', 'GRASSROOTS_MOVEMENT'], // May
|
||||
['STAY_COURSE', 'COUNTER_CAMPAIGN'], // Alert
|
||||
['EXPERT_PANEL', 'ACADEMIC_OUTREACH'], // July
|
||||
['RESEARCH_PAPER', 'CONSPIRACY_DOCUMENTARY'], // September
|
||||
['PODCAST_PLATFORMS', 'CELEBRITY_ENDORSEMENT'], // November
|
||||
['EVENT_STRATEGY', 'PLATFORM_POLICY'], // December
|
||||
['FREEDOM_DEFENSE', 'MEDIA_BIAS'] // Exposé
|
||||
];
|
||||
|
||||
const Index = () => {
|
||||
const { t } = useTranslation();
|
||||
const stages = useGameStages();
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const stages = useGameStages(audioRef);
|
||||
const operationNameKey = OPERATION_NAMES[Math.floor(Math.random() * OPERATION_NAMES.length)];
|
||||
const operationName = t(`operations.${operationNameKey}`);
|
||||
const [agentNumber] = useState(Math.floor(Math.random() * 999).toString().padStart(3, '0'));
|
||||
@ -54,7 +83,6 @@ const Index = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingMessage, setLoadingMessage] = useState('');
|
||||
const [loadingProgress, setLoadingProgress] = useState(0);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [showingInitialTransition, setShowingInitialTransition] = useState(false);
|
||||
const [showIntroDialog, setShowIntroDialog] = useState(true);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
@ -65,6 +93,47 @@ const Index = () => {
|
||||
const [gameKey, setGameKey] = useState(0);
|
||||
const loadingMessages = useLoadingMessages();
|
||||
const [shouldStartAudio, setShouldStartAudio] = useState(false);
|
||||
const [showDevPanel, setShowDevPanel] = useState(false);
|
||||
const [showFinalFade, setShowFinalFade] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.key === 'd') {
|
||||
e.preventDefault();
|
||||
setShowDevPanel(prev => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const handleJumpToMonth = (monthIndex: number) => {
|
||||
setCurrentStage(monthIndex);
|
||||
setShowDevPanel(false);
|
||||
setGameStarted(true);
|
||||
setShowIntroDialog(false);
|
||||
setShowingInitialTransition(false);
|
||||
};
|
||||
|
||||
const handleRandomizeChoices = () => {
|
||||
const randomChoices: ChoiceID[] = [];
|
||||
|
||||
// For each stage up to current stage, randomly select between A or B
|
||||
for (let i = 0; i < currentStage; i++) {
|
||||
const stagePair = STAGE_CHOICES[i];
|
||||
// Randomly select 0 or 1 to pick between the two choices
|
||||
const randomIndex = Math.floor(Math.random() * 2);
|
||||
const choiceId = stagePair[randomIndex] as ChoiceID;
|
||||
randomChoices.push(choiceId);
|
||||
|
||||
// Log the choice in a readable format
|
||||
console.log(`${monthKeys[i].toUpperCase()}: ${choiceId}`);
|
||||
}
|
||||
|
||||
setPreviousChoices(randomChoices);
|
||||
setShowDevPanel(false);
|
||||
};
|
||||
|
||||
const handleStartGame = () => {
|
||||
playAcceptMissionSound();
|
||||
@ -83,7 +152,16 @@ const Index = () => {
|
||||
|
||||
const handleChoice = async (choice: GameStage["choices"][0]) => {
|
||||
if (!choice.choiceId) return; // Skip if no choiceId
|
||||
setPreviousChoices(prev => [...prev, choice.choiceId as ChoiceID]);
|
||||
const newChoices = [...previousChoices, choice.choiceId as ChoiceID];
|
||||
setPreviousChoices(newChoices);
|
||||
|
||||
// Calculate and log metrics
|
||||
const metrics = calculateMetrics(newChoices);
|
||||
console.log('\nMetrics after choice:', choice.text);
|
||||
console.log('Network Reach:', metrics.reach + '%');
|
||||
console.log('Core Loyalists:', metrics.loyalists + '%');
|
||||
console.log('Virality Multiplier:', metrics.virality + 'x');
|
||||
|
||||
playDeployStratagemSound();
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
@ -108,6 +186,20 @@ const Index = () => {
|
||||
setLoadingProgress((elapsed / totalDuration) * 100);
|
||||
}
|
||||
|
||||
// For the final stage (Exposé), let the loading overlay stay visible
|
||||
// and transition smoothly into the EndGameDialog's black overlay
|
||||
if (currentStage === stages.length - 1) {
|
||||
// Keep loading overlay at 100% for a moment
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
// Start the fade to black and fade out loading overlay
|
||||
setShowFinalFade(true);
|
||||
setIsLoading(false);
|
||||
// Wait for fade to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
setGameComplete(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setCurrentResult(choice.result);
|
||||
setShowingResult(true);
|
||||
@ -131,11 +223,6 @@ const Index = () => {
|
||||
|
||||
setDossierEntries(prev => [...prev, newEntry]);
|
||||
|
||||
toast({
|
||||
title: t('analysis.intelligenceGathered.title'),
|
||||
description: t('analysis.intelligenceGathered.description'),
|
||||
});
|
||||
|
||||
if (currentStage === stages.length - 1) {
|
||||
setGameComplete(true);
|
||||
}
|
||||
@ -193,8 +280,11 @@ const Index = () => {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
// Stop the final music when restarting
|
||||
stopBackgroundMusic();
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (!gameStarted) {
|
||||
if (showingInitialTransition) {
|
||||
return (
|
||||
@ -224,6 +314,7 @@ const Index = () => {
|
||||
<Lock className="w-3 h-3 mr-1" />
|
||||
{t('mission.topSecret')}
|
||||
</Badge>
|
||||
<MuteButton />
|
||||
<Badge variant="outline" className="text-red-500 border-red-500">
|
||||
<AlertCircle className="w-3 h-3 mr-1" />
|
||||
{t('mission.classified')}
|
||||
@ -253,7 +344,6 @@ const Index = () => {
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex justify-between items-center text-sm border-b border-gray-700 pb-3">
|
||||
<p className="font-mono text-yellow-500 font-semibold tracking-wider">{t('mission.directorate')}</p>
|
||||
<IntroAudio />
|
||||
</div>
|
||||
<div className="text-gray-300 font-mono text-sm space-y-1">
|
||||
<p>{t('mission.to', { agentNumber })}</p>
|
||||
@ -263,6 +353,14 @@ const Index = () => {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-gray-300 font-mono leading-relaxed space-y-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h2 className="text-yellow-500 font-mono text-sm">Briefing Audio</h2>
|
||||
<BriefingAudio
|
||||
stage="INTRO"
|
||||
audioRef={audioRef}
|
||||
className="bg-transparent hover:bg-black/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<p>{t('mission.briefing.part1')}</p>
|
||||
</div>
|
||||
@ -302,12 +400,20 @@ const Index = () => {
|
||||
const currentStageData = stages[currentStage];
|
||||
|
||||
if (gameComplete) {
|
||||
return <FinalMemo
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 1 }}
|
||||
className="fixed inset-0 bg-black z-40"
|
||||
/>
|
||||
<FinalMemo
|
||||
key={gameKey}
|
||||
choices={previousChoices}
|
||||
onRestart={handleRestart}
|
||||
agentNumber={agentNumber}
|
||||
/>;
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentStageData) {
|
||||
@ -351,6 +457,9 @@ const Index = () => {
|
||||
<Card className="w-full md:max-w-2xl bg-black/50 text-white border-gray-700 transition-all duration-1000 animate-fade-in">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<CardDescription className="text-emerald-400/90 italic">
|
||||
{t('analysis.intelligenceGathered.description')}
|
||||
</CardDescription>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle className="text-xl md:text-2xl text-yellow-500">{currentResult.title}</CardTitle>
|
||||
</div>
|
||||
@ -429,22 +538,18 @@ const Index = () => {
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<BriefingAudio
|
||||
stage={currentStageData.monthIndex.toString()}
|
||||
audioRef={audioRef}
|
||||
className="self-start"
|
||||
/>
|
||||
<LanguageSwitcher />
|
||||
<MuteButton />
|
||||
<span className="text-yellow-500 font-mono text-lg">{t(`months.${monthKeys[currentStageData.monthIndex]}`)}</span>
|
||||
</div>
|
||||
{currentStage > 0 && <DossierPanel entries={dossierEntries} choices={previousChoices} />}
|
||||
</div>
|
||||
<CardTitle>{currentStageData.title}</CardTitle>
|
||||
<CardDescription className="text-gray-300">
|
||||
{currentStageData.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{currentStageData.choices.map((choice, index) => (
|
||||
<ChoiceCard
|
||||
key={choice.id}
|
||||
@ -455,7 +560,13 @@ const Index = () => {
|
||||
optionNumber={index + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
<div className="mt-4 border-t border-gray-700/50">
|
||||
<div className="flex justify-center py-4">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
@ -533,9 +644,30 @@ const Index = () => {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{showFinalFade && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1.5 }}
|
||||
className="fixed inset-0 bg-black z-[50]"
|
||||
/>
|
||||
)}
|
||||
{isLoading && <LoadingOverlay message={loadingMessage} progress={loadingProgress} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderContent()}
|
||||
<DevPanel
|
||||
open={showDevPanel}
|
||||
onOpenChange={setShowDevPanel}
|
||||
onJumpToMonth={handleJumpToMonth}
|
||||
onRandomizeChoices={handleRandomizeChoices}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
|
||||
@ -1,18 +1,106 @@
|
||||
const audioCache: { [key: string]: HTMLAudioElement } = {};
|
||||
let isMuted = false;
|
||||
let backgroundMusic: HTMLAudioElement | null = null;
|
||||
let finalMusic: HTMLAudioElement | null = null;
|
||||
|
||||
export function setMuted(muted: boolean) {
|
||||
isMuted = muted;
|
||||
// Update all cached audio elements
|
||||
Object.values(audioCache).forEach(audio => {
|
||||
audio.muted = isMuted;
|
||||
});
|
||||
// Update background music
|
||||
if (backgroundMusic) {
|
||||
backgroundMusic.muted = isMuted;
|
||||
}
|
||||
if (finalMusic) {
|
||||
finalMusic.muted = isMuted;
|
||||
}
|
||||
}
|
||||
|
||||
export function getMuted(): boolean {
|
||||
return isMuted;
|
||||
}
|
||||
|
||||
export function startBackgroundMusic() {
|
||||
if (!backgroundMusic) {
|
||||
backgroundMusic = new Audio("/tension-background.mp3");
|
||||
backgroundMusic.loop = true;
|
||||
backgroundMusic.volume = 0.3;
|
||||
backgroundMusic.muted = isMuted;
|
||||
backgroundMusic.play().catch(console.error);
|
||||
} else if (backgroundMusic.paused) {
|
||||
backgroundMusic.play().catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
export function stopBackgroundMusic() {
|
||||
if (backgroundMusic) {
|
||||
backgroundMusic.pause();
|
||||
backgroundMusic.currentTime = 0;
|
||||
backgroundMusic = null;
|
||||
}
|
||||
if (finalMusic) {
|
||||
finalMusic.pause();
|
||||
finalMusic.currentTime = 0;
|
||||
finalMusic = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function switchToFinalMusic() {
|
||||
// Fade out current background music
|
||||
if (backgroundMusic) {
|
||||
const fadeOut = setInterval(() => {
|
||||
if (backgroundMusic && backgroundMusic.volume > 0.05) {
|
||||
backgroundMusic.volume -= 0.05;
|
||||
} else {
|
||||
clearInterval(fadeOut);
|
||||
stopBackgroundMusic();
|
||||
// Start final music
|
||||
finalMusic = new Audio("/final-theme.mp3");
|
||||
finalMusic.loop = true;
|
||||
finalMusic.volume = 0;
|
||||
finalMusic.muted = isMuted;
|
||||
finalMusic.play().catch(console.error);
|
||||
// Fade in final music
|
||||
const fadeIn = setInterval(() => {
|
||||
if (finalMusic && finalMusic.volume < 0.3) {
|
||||
finalMusic.volume += 0.05;
|
||||
} else {
|
||||
clearInterval(fadeIn);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Create or get a cached audio element
|
||||
function createAudio(src: string): HTMLAudioElement {
|
||||
if (audioCache[src]) {
|
||||
return audioCache[src];
|
||||
}
|
||||
const audio = new Audio(src);
|
||||
audio.muted = isMuted;
|
||||
audioCache[src] = audio;
|
||||
return audio;
|
||||
}
|
||||
|
||||
// Play a briefing audio file
|
||||
export function playBriefing(src: string): HTMLAudioElement {
|
||||
const audio = createAudio(src);
|
||||
audio.volume = 1;
|
||||
audio.muted = isMuted;
|
||||
audio.play().catch(err => console.error('Briefing audio playback failed:', err));
|
||||
return audio;
|
||||
}
|
||||
|
||||
// Play a sound effect
|
||||
export function playSound(src: string, volume = 0.75) {
|
||||
const audio = createAudio(src);
|
||||
audio.volume = volume;
|
||||
audio.currentTime = 0;
|
||||
audio.muted = isMuted;
|
||||
audio.play().catch(err => console.error('Audio playback failed:', err));
|
||||
}
|
||||
|
||||
|
||||
Загрузка…
x
Ссылка в новой задаче
Block a user