feat(games,lyra): GameOverScreen migration + Lyra markdown-strip
GAMES (Nuxt → RN migration): - New components/games/GameOverScreen.tsx — slide-in + fade overlay Props: score, bestScore, gameName, onRetry, onExit, isNewBest - New lib/gameScores.ts — AsyncStorage helpers rebreak_best_snake (higher=better), _tetris (higher=better), _memory (lower=better, inverted isNewBest) - UrgeGames.tsx wired: snake-collision/tetris-topout/memory-finish trigger GameOverScreen with retry/exit + best-score persist - TicTacToe NICHT — round-aggregation game hat eigenen Fertig-Flow - 7 i18n keys (gameOver.* DE+EN, 5 motivational texts statisch aus pool) LYRA (markdown-bug fix): - User-Report: Lyra antwortet mit ** in mobile-app, verwirrt user - Beide system-prompts (COACH_SYSTEM_PROMPT für SOS, COACH_CASUAL_SYSTEM_PROMPT für Coach) bekommen "ANTWORTFORMAT - KRITISCH"-section: NIE Markdown (kein **bold**, _italic_, #-Headings, -Bullets) — Klartext only - Reason: Mobile-App-bubbles rendern markdown nicht → User sieht raw `**text**` Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
33108a6774
commit
376f3454d6
256
apps/rebreak-native/components/games/GameOverScreen.tsx
Normal file
256
apps/rebreak-native/components/games/GameOverScreen.tsx
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Animated, Pressable, Text, View } from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
|
export type GameOverScreenProps = {
|
||||||
|
score: number;
|
||||||
|
bestScore: number;
|
||||||
|
gameName: string;
|
||||||
|
onRetry: () => void;
|
||||||
|
onExit: () => void;
|
||||||
|
isNewBest?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOTIVATIONAL_KEYS = [
|
||||||
|
'gameOver.motivational_0',
|
||||||
|
'gameOver.motivational_1',
|
||||||
|
'gameOver.motivational_2',
|
||||||
|
'gameOver.motivational_3',
|
||||||
|
'gameOver.motivational_4',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function GameOverScreen({
|
||||||
|
score,
|
||||||
|
bestScore,
|
||||||
|
gameName,
|
||||||
|
onRetry,
|
||||||
|
onExit,
|
||||||
|
isNewBest = false,
|
||||||
|
}: GameOverScreenProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
|
||||||
|
const slideAnim = useRef(new Animated.Value(40)).current;
|
||||||
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, tension: 60, friction: 10 }),
|
||||||
|
Animated.timing(fadeAnim, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||||
|
]).start();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const motivationalKey = MOTIVATIONAL_KEYS[score % MOTIVATIONAL_KEYS.length]!;
|
||||||
|
const fmt = (n: number) => String(n).padStart(5, '0');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 24,
|
||||||
|
opacity: fadeAnim,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<Pressable
|
||||||
|
onPress={onExit}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ translateY: slideAnim }],
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 340,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 20,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 20,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.18,
|
||||||
|
shadowRadius: 20,
|
||||||
|
elevation: 12,
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Title row */}
|
||||||
|
<View style={{ alignItems: 'center', gap: 4 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Nunito_800ExtraBold',
|
||||||
|
fontSize: 22,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('gameOver.title')}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.textMuted,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{gameName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Score row */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Score */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Courier New' as any,
|
||||||
|
fontSize: 22,
|
||||||
|
color: '#00e680',
|
||||||
|
letterSpacing: 2,
|
||||||
|
fontVariant: ['tabular-nums'],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmt(score)}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: colors.textMuted,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('gameOver.score')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Best */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: isNewBest ? '#fef3c7' : colors.surfaceElevated,
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: isNewBest ? 1.5 : 0,
|
||||||
|
borderColor: isNewBest ? '#f59e0b' : 'transparent',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Courier New' as any,
|
||||||
|
fontSize: 22,
|
||||||
|
color: isNewBest ? '#d97706' : colors.textMuted,
|
||||||
|
letterSpacing: 2,
|
||||||
|
fontVariant: ['tabular-nums'],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmt(Math.max(score, bestScore))}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: isNewBest ? '#d97706' : colors.textMuted,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isNewBest ? t('gameOver.newBest') : t('gameOver.best')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Motivational text */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.textMuted,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 19,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(motivationalKey)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<View style={{ flexDirection: 'row', gap: 10 }}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||||
|
onRetry();
|
||||||
|
}}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 13,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#007AFF',
|
||||||
|
alignItems: 'center',
|
||||||
|
opacity: pressed ? 0.75 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: '#ffffff' }}>
|
||||||
|
{t('gameOver.retry')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
||||||
|
onExit();
|
||||||
|
}}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 13,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
alignItems: 'center',
|
||||||
|
opacity: pressed ? 0.75 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: colors.textMuted }}>
|
||||||
|
{t('gameOver.exit')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
import { View, Text, Pressable, Dimensions, PanResponder, Platform } from 'react-native';
|
import { View, Text, Pressable, Dimensions, PanResponder, Platform } from 'react-native';
|
||||||
import Svg, { Defs, Pattern, Path, Rect, Polyline, Circle, Line } from 'react-native-svg';
|
import Svg, { Defs, Pattern, Path, Rect, Polyline, Circle, Line } from 'react-native-svg';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import { SvgXml } from 'react-native-svg';
|
import { SvgXml } from 'react-native-svg';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -9,6 +8,9 @@ import * as Haptics from 'expo-haptics';
|
|||||||
import Slider from '@react-native-community/slider';
|
import Slider from '@react-native-community/slider';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { memorySvg, snakeSvg, tetrisSvg, tictactoeSvg } from './gameSvgs';
|
import { memorySvg, snakeSvg, tetrisSvg, tictactoeSvg } from './gameSvgs';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
import { GameOverScreen } from '../games/GameOverScreen';
|
||||||
|
import { getBestScore, saveBestScore } from '../../lib/gameScores';
|
||||||
|
|
||||||
// Haptic helper — fire-and-forget, swallow errors on platforms without taptic engine
|
// Haptic helper — fire-and-forget, swallow errors on platforms without taptic engine
|
||||||
function tapHaptic() {
|
function tapHaptic() {
|
||||||
@ -134,14 +136,12 @@ export function SnakeGame({
|
|||||||
const [score, setScore] = useState(0);
|
const [score, setScore] = useState(0);
|
||||||
const [highScore, setHighScore] = useState(0);
|
const [highScore, setHighScore] = useState(0);
|
||||||
const [gameOver, setGameOver] = useState(false);
|
const [gameOver, setGameOver] = useState(false);
|
||||||
|
const [isNewBest, setIsNewBest] = useState(false);
|
||||||
const [activeDPad, setActiveDPad] = useState<Dir>('right');
|
const [activeDPad, setActiveDPad] = useState<Dir>('right');
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
// Load high score
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
AsyncStorage.getItem('rebreak-snake-highscore').then((v) => {
|
getBestScore('snake').then(setHighScore);
|
||||||
if (v) setHighScore(parseInt(v) || 0);
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function setDir(d: Dir) {
|
function setDir(d: Dir) {
|
||||||
@ -165,10 +165,24 @@ export function SnakeGame({
|
|||||||
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
|
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
|
||||||
setGameOver(true);
|
setGameOver(true);
|
||||||
if (finalScore > highScore) {
|
if (finalScore > highScore) {
|
||||||
|
setIsNewBest(true);
|
||||||
setHighScore(finalScore);
|
setHighScore(finalScore);
|
||||||
AsyncStorage.setItem('rebreak-snake-highscore', String(finalScore)).catch(() => {});
|
saveBestScore('snake', finalScore).catch(() => {});
|
||||||
}
|
}
|
||||||
setTimeout(() => onComplete(finalScore), 500);
|
}
|
||||||
|
|
||||||
|
function resetSnake() {
|
||||||
|
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
|
||||||
|
setSnake([{ row: 10, col: 7 }, { row: 10, col: 6 }, { row: 10, col: 5 }]);
|
||||||
|
snakeRef.current = [{ row: 10, col: 7 }, { row: 10, col: 6 }, { row: 10, col: 5 }];
|
||||||
|
setFood({ row: 3, col: 10 });
|
||||||
|
foodRef.current = { row: 3, col: 10 };
|
||||||
|
dirRef.current = 'right';
|
||||||
|
nextDirRef.current = 'right';
|
||||||
|
setScore(0);
|
||||||
|
setGameOver(false);
|
||||||
|
setIsNewBest(false);
|
||||||
|
setActiveDPad('right');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Game tick loop — single setInterval, side-effects driven via refs (NOT inside reducers).
|
// Game tick loop — single setInterval, side-effects driven via refs (NOT inside reducers).
|
||||||
@ -280,24 +294,17 @@ export function SnakeGame({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16) }}>
|
<View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16), position: 'relative' }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
<Text style={{ fontSize: 11, color: '#6b7280', flex: 1, marginRight: 8 }} numberOfLines={2}>{lyraMessage}</Text>
|
<Text style={{ fontSize: 11, color: '#6b7280', flex: 1, marginRight: 8 }} numberOfLines={2}>{lyraMessage}</Text>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 14 }}>
|
|
||||||
<View style={{ alignItems: 'center' }}>
|
|
||||||
<Text style={{ fontSize: 9, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: 0.5 }}>Score</Text>
|
|
||||||
<Text style={{ fontSize: 18, fontFamily: 'Nunito_800ExtraBold', color: '#16a34a' }}>{score}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={{ alignItems: 'center' }}>
|
|
||||||
<Text style={{ fontSize: 9, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: 0.5 }}>Best</Text>
|
|
||||||
<Text style={{ fontSize: 18, fontFamily: 'Nunito_800ExtraBold', color: '#111827' }}>{highScore}</Text>
|
|
||||||
</View>
|
|
||||||
<Pressable onPress={onAbandon} hitSlop={10} style={{ width: 28, height: 28, alignItems: 'center', justifyContent: 'center' }}>
|
<Pressable onPress={onAbandon} hitSlop={10} style={{ width: 28, height: 28, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<Text style={{ fontSize: 18, color: '#6b7280' }}>✕</Text>
|
<Text style={{ fontSize: 18, color: '#6b7280' }}>✕</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
{/* Digital score dashboard */}
|
||||||
|
<DigitalScore score={score} best={highScore} boardWidth={boardW} />
|
||||||
|
|
||||||
{/* Board */}
|
{/* Board */}
|
||||||
<View style={{ alignItems: 'center' }} {...panResponder.panHandlers}>
|
<View style={{ alignItems: 'center' }} {...panResponder.panHandlers}>
|
||||||
@ -343,17 +350,19 @@ export function SnakeGame({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{gameOver && (
|
{gameOver && (
|
||||||
<View style={{ marginTop: 14, alignItems: 'center', gap: 10 }}>
|
<GameOverScreen
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#dc2626' }}>Game Over</Text>
|
score={score}
|
||||||
<Text style={{ fontSize: 14, color: '#6b7280' }}>{score} {score === 1 ? 'Apfel' : 'Äpfel'} gesammelt</Text>
|
bestScore={highScore}
|
||||||
</View>
|
gameName="Snake"
|
||||||
|
isNewBest={isNewBest}
|
||||||
|
onRetry={resetSnake}
|
||||||
|
onExit={() => onAbandon()}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Platform-native D-Pad button: iOS uses system-blue tinted circle (SF-symbol look),
|
|
||||||
// Android uses Material elevated surface with ripple.
|
|
||||||
function DPadBtn({ dir, active, onPress }: { dir: Dir; active: boolean; onPress: () => void }) {
|
function DPadBtn({ dir, active, onPress }: { dir: Dir; active: boolean; onPress: () => void }) {
|
||||||
const icons: Record<Dir, 'chevron-up' | 'chevron-down' | 'chevron-back' | 'chevron-forward'> = {
|
const icons: Record<Dir, 'chevron-up' | 'chevron-down' | 'chevron-back' | 'chevron-forward'> = {
|
||||||
up: 'chevron-up', down: 'chevron-down', left: 'chevron-back', right: 'chevron-forward',
|
up: 'chevron-up', down: 'chevron-down', left: 'chevron-back', right: 'chevron-forward',
|
||||||
@ -366,29 +375,24 @@ function DPadBtn({ dir, active, onPress }: { dir: Dir; active: boolean; onPress:
|
|||||||
hitSlop={12}
|
hitSlop={12}
|
||||||
android_ripple={{ color: 'rgba(0,122,255,0.22)', borderless: true, radius: 32 }}
|
android_ripple={{ color: 'rgba(0,122,255,0.22)', borderless: true, radius: 32 }}
|
||||||
style={({ pressed }) => {
|
style={({ pressed }) => {
|
||||||
const bgIdle = isIOS ? 'rgba(0,122,255,0.10)' : '#ffffff';
|
const bgIdle = 'rgba(0,122,255,0.10)';
|
||||||
const bgPressed = isIOS ? 'rgba(0,122,255,0.22)' : '#f5f5f5';
|
const bgPressed = 'rgba(0,122,255,0.22)';
|
||||||
const bgActive = tint;
|
const bgActive = 'rgba(0,122,255,0.22)';
|
||||||
const bg = active ? bgActive : (pressed && isIOS ? bgPressed : bgIdle);
|
const bg = active ? bgActive : pressed ? bgPressed : bgIdle;
|
||||||
return {
|
return {
|
||||||
width: 60, height: 60, borderRadius: 30,
|
width: 60, height: 60, borderRadius: 30,
|
||||||
backgroundColor: bg,
|
backgroundColor: bg,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: active ? tint : 'rgba(0,122,255,0.30)',
|
||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
...(isIOS ? {} : {
|
transform: [{ scale: pressed ? 0.96 : active ? 1.04 : 1 }],
|
||||||
elevation: active ? 4 : 2,
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
shadowOpacity: 0.15,
|
|
||||||
shadowRadius: 2,
|
|
||||||
}),
|
|
||||||
transform: [{ scale: pressed && isIOS ? 0.96 : 1 }],
|
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={icons[dir]}
|
name={icons[dir]}
|
||||||
size={28}
|
size={28}
|
||||||
color={active ? '#ffffff' : tint}
|
color={tint}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@ -455,6 +459,11 @@ export function MemoryGame({
|
|||||||
const [moveCount, setMoveCount] = useState(0);
|
const [moveCount, setMoveCount] = useState(0);
|
||||||
const [matchedCount, setMatchedCount] = useState(0);
|
const [matchedCount, setMatchedCount] = useState(0);
|
||||||
const [blocked, setBlocked] = useState(false);
|
const [blocked, setBlocked] = useState(false);
|
||||||
|
const [showGameOver, setShowGameOver] = useState(false);
|
||||||
|
const [bestMoves, setBestMoves] = useState(0);
|
||||||
|
const [isNewBestMemory, setIsNewBestMemory] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { getBestScore('memory').then(setBestMoves); }, []);
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
const pairs = shuffle([...MEMORY_EMOJIS, ...MEMORY_EMOJIS]);
|
const pairs = shuffle([...MEMORY_EMOJIS, ...MEMORY_EMOJIS]);
|
||||||
@ -463,6 +472,8 @@ export function MemoryGame({
|
|||||||
setMoveCount(0);
|
setMoveCount(0);
|
||||||
setMatchedCount(0);
|
setMatchedCount(0);
|
||||||
setBlocked(false);
|
setBlocked(false);
|
||||||
|
setShowGameOver(false);
|
||||||
|
setIsNewBestMemory(false);
|
||||||
}
|
}
|
||||||
useEffect(() => { init(); }, []);
|
useEffect(() => { init(); }, []);
|
||||||
|
|
||||||
@ -499,7 +510,13 @@ export function MemoryGame({
|
|||||||
const newMatched = matchedCount + 1;
|
const newMatched = matchedCount + 1;
|
||||||
setMatchedCount(newMatched);
|
setMatchedCount(newMatched);
|
||||||
if (newMatched === MEMORY_PAIRS) {
|
if (newMatched === MEMORY_PAIRS) {
|
||||||
setTimeout(() => onComplete(newMoveCount), 600);
|
const newBest = bestMoves === 0 || newMoveCount < bestMoves;
|
||||||
|
if (newBest) {
|
||||||
|
setIsNewBestMemory(true);
|
||||||
|
setBestMoves(newMoveCount);
|
||||||
|
saveBestScore('memory', newMoveCount).catch(() => {});
|
||||||
|
}
|
||||||
|
setTimeout(() => setShowGameOver(true), 600);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setBlocked(true);
|
setBlocked(true);
|
||||||
@ -516,7 +533,7 @@ export function MemoryGame({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ paddingHorizontal: 12 }}>
|
<View style={{ paddingHorizontal: 12, position: 'relative' }}>
|
||||||
{/* Lyra Header */}
|
{/* Lyra Header */}
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: '#f9fafb', borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 16, paddingHorizontal: 14, paddingVertical: 10, gap: 12 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: '#f9fafb', borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 16, paddingHorizontal: 14, paddingVertical: 10, gap: 12 }}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
@ -559,6 +576,16 @@ export function MemoryGame({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
|
{showGameOver && (
|
||||||
|
<GameOverScreen
|
||||||
|
score={moveCount}
|
||||||
|
bestScore={bestMoves}
|
||||||
|
gameName="Memory"
|
||||||
|
isNewBest={isNewBestMemory}
|
||||||
|
onRetry={init}
|
||||||
|
onExit={() => onAbandon()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -795,6 +822,7 @@ export function TetrisGame({
|
|||||||
const [level, setLevel] = useState(1);
|
const [level, setLevel] = useState(1);
|
||||||
const [lines, setLines] = useState(0);
|
const [lines, setLines] = useState(0);
|
||||||
const [gameOver, setGameOver] = useState(false);
|
const [gameOver, setGameOver] = useState(false);
|
||||||
|
const [isNewBestTetris, setIsNewBestTetris] = useState(false);
|
||||||
const [highScore, setHighScore] = useState(0);
|
const [highScore, setHighScore] = useState(0);
|
||||||
const [speedLevel, setSpeedLevel] = useState(3);
|
const [speedLevel, setSpeedLevel] = useState(3);
|
||||||
const tickTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const tickTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@ -804,11 +832,8 @@ export function TetrisGame({
|
|||||||
useEffect(() => { boardRef.current = board; }, [board]);
|
useEffect(() => { boardRef.current = board; }, [board]);
|
||||||
useEffect(() => { currentRef.current = current; }, [current]);
|
useEffect(() => { currentRef.current = current; }, [current]);
|
||||||
|
|
||||||
// Load high score
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
AsyncStorage.getItem('rebreak-tetris-highscore').then((v) => {
|
getBestScore('tetris').then(setHighScore);
|
||||||
if (v) setHighScore(parseInt(v) || 0);
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function isValid(piece: TetrisPiece, px: number, py: number, shape = piece.shape): boolean {
|
function isValid(piece: TetrisPiece, px: number, py: number, shape = piece.shape): boolean {
|
||||||
@ -834,10 +859,10 @@ export function TetrisGame({
|
|||||||
stopTick();
|
stopTick();
|
||||||
const finalScore = score;
|
const finalScore = score;
|
||||||
if (finalScore > highScore) {
|
if (finalScore > highScore) {
|
||||||
|
setIsNewBestTetris(true);
|
||||||
setHighScore(finalScore);
|
setHighScore(finalScore);
|
||||||
AsyncStorage.setItem('rebreak-tetris-highscore', String(finalScore)).catch(() => {});
|
saveBestScore('tetris', finalScore).catch(() => {});
|
||||||
}
|
}
|
||||||
setTimeout(() => onComplete(finalScore), 500);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCurrent(newPiece);
|
setCurrent(newPiece);
|
||||||
@ -920,6 +945,22 @@ export function TetrisGame({
|
|||||||
}
|
}
|
||||||
function resetTick() { stopTick(); startTick(); }
|
function resetTick() { stopTick(); startTick(); }
|
||||||
|
|
||||||
|
function resetTetris() {
|
||||||
|
stopTick();
|
||||||
|
const freshBoard = tetrisEmptyBoard();
|
||||||
|
setBoard(freshBoard);
|
||||||
|
boardRef.current = freshBoard;
|
||||||
|
setCurrent(null);
|
||||||
|
currentRef.current = null;
|
||||||
|
nextPieceRef.current = tetrisRandomPiece();
|
||||||
|
setScore(0);
|
||||||
|
setLevel(1);
|
||||||
|
setLines(0);
|
||||||
|
setGameOver(false);
|
||||||
|
setIsNewBestTetris(false);
|
||||||
|
setTimeout(() => { spawnPiece(); startTick(); }, 0);
|
||||||
|
}
|
||||||
|
|
||||||
// Init
|
// Init
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
spawnPiece();
|
spawnPiece();
|
||||||
@ -966,19 +1007,18 @@ export function TetrisGame({
|
|||||||
|
|
||||||
const speedColors = ['#22c55e', '#84cc16', '#eab308', '#f97316', '#ef4444'];
|
const speedColors = ['#22c55e', '#84cc16', '#eab308', '#f97316', '#ef4444'];
|
||||||
|
|
||||||
|
const boardWidth = TETRIS_COLS * CELL;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16) }}>
|
<View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16), position: 'relative' }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
<Text style={{ fontSize: 11, color: '#6b7280', flex: 1, marginRight: 8 }} numberOfLines={2}>{lyraMessage}</Text>
|
<Text style={{ fontSize: 11, color: '#6b7280', flex: 1, marginRight: 8 }} numberOfLines={2}>{lyraMessage}</Text>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
|
||||||
<Stat label="Score" value={score} color="#111827" />
|
|
||||||
{highScore > 0 && <Stat label="Best" value={highScore} color="#f59e0b" />}
|
|
||||||
<Stat label="Lvl" value={level} color="#3b82f6" />
|
|
||||||
<Stat label="Lines" value={lines} color="#111827" />
|
|
||||||
<Pressable onPress={onAbandon} hitSlop={10}><Text style={{ fontSize: 18, color: '#6b7280' }}>✕</Text></Pressable>
|
<Pressable onPress={onAbandon} hitSlop={10}><Text style={{ fontSize: 18, color: '#6b7280' }}>✕</Text></Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
{/* Digital score dashboard */}
|
||||||
|
<DigitalScore score={score} best={highScore} extra={level} extraLabel="LVL" boardWidth={boardWidth} />
|
||||||
|
|
||||||
{/* Board */}
|
{/* Board */}
|
||||||
<View style={{ alignItems: 'center', marginVertical: 4 }}>
|
<View style={{ alignItems: 'center', marginVertical: 4 }}>
|
||||||
@ -1037,8 +1077,14 @@ export function TetrisGame({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Controls — Move Pad (links) + Action Pad (rechts) */}
|
{/* Controls — aligned to board width, centered on screen */}
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 18 }}>
|
<View style={{ alignItems: 'center', marginTop: 18 }}>
|
||||||
|
<View style={{
|
||||||
|
width: boardWidth,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}>
|
||||||
{/* Move Pad */}
|
{/* Move Pad */}
|
||||||
<View style={{ flexDirection: 'row', gap: 14 }}>
|
<View style={{ flexDirection: 'row', gap: 14 }}>
|
||||||
<DPadBtn dir="left" active={false} onPress={moveLeft} />
|
<DPadBtn dir="left" active={false} onPress={moveLeft} />
|
||||||
@ -1050,22 +1096,92 @@ export function TetrisGame({
|
|||||||
<TetrisActionBtn icon="arrow-down" label="Drop" accent="#0ea5e9" onPress={softDrop} />
|
<TetrisActionBtn icon="arrow-down" label="Drop" accent="#0ea5e9" onPress={softDrop} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
{gameOver && (
|
{gameOver && (
|
||||||
<View style={{ marginTop: 14, alignItems: 'center', gap: 6 }}>
|
<GameOverScreen
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#dc2626' }}>Game Over</Text>
|
score={score}
|
||||||
<Text style={{ fontSize: 14, color: '#6b7280' }}>{score} Punkte · {lines} Linien</Text>
|
bestScore={highScore}
|
||||||
</View>
|
gameName="Tetris"
|
||||||
|
isNewBest={isNewBestTetris}
|
||||||
|
onRetry={resetTetris}
|
||||||
|
onExit={() => onAbandon()}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
||||||
|
const colors = useColors();
|
||||||
return (
|
return (
|
||||||
<View style={{ alignItems: 'center' }}>
|
<View style={{ alignItems: 'center' }}>
|
||||||
<Text style={{ fontSize: 9, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: 0.5 }}>{label}</Text>
|
<Text style={{ fontSize: 9, color: colors.textMuted, textTransform: 'uppercase', letterSpacing: 0.5 }}>{label}</Text>
|
||||||
<Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color }}>{value}</Text>
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color }}>{value}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DigitalScore({
|
||||||
|
score,
|
||||||
|
best,
|
||||||
|
extra,
|
||||||
|
extraLabel,
|
||||||
|
boardWidth,
|
||||||
|
}: {
|
||||||
|
score: number;
|
||||||
|
best: number;
|
||||||
|
extra?: number;
|
||||||
|
extraLabel?: string;
|
||||||
|
boardWidth: number;
|
||||||
|
}) {
|
||||||
|
const fmt = (n: number, digits = 5) => String(n).padStart(digits, '0');
|
||||||
|
return (
|
||||||
|
<View style={{
|
||||||
|
width: boardWidth,
|
||||||
|
alignSelf: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#0d1117',
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1f2937',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 7,
|
||||||
|
marginBottom: 6,
|
||||||
|
}}>
|
||||||
|
<ScoreCell label="SCORE" value={fmt(score)} bright />
|
||||||
|
<View style={{ width: 1, height: 28, backgroundColor: '#1f2937' }} />
|
||||||
|
<ScoreCell label="BEST" value={fmt(best)} />
|
||||||
|
{extra !== undefined && extraLabel !== undefined && (
|
||||||
|
<>
|
||||||
|
<View style={{ width: 1, height: 28, backgroundColor: '#1f2937' }} />
|
||||||
|
<ScoreCell label={extraLabel} value={fmt(extra, 2)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoreCell({ label, value, bright }: { label: string; value: string; bright?: boolean }) {
|
||||||
|
return (
|
||||||
|
<View style={{ alignItems: 'center', flex: 1 }}>
|
||||||
|
<Text style={{
|
||||||
|
fontSize: 9,
|
||||||
|
color: '#4b5563',
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}>{label}</Text>
|
||||||
|
<Text style={{
|
||||||
|
fontSize: 18,
|
||||||
|
fontFamily: 'Courier New' as any,
|
||||||
|
fontVariant: ['tabular-nums'],
|
||||||
|
color: bright ? '#00e680' : '#6b7280',
|
||||||
|
letterSpacing: 2,
|
||||||
|
lineHeight: 22,
|
||||||
|
}}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
16
apps/rebreak-native/lib/gameScores.ts
Normal file
16
apps/rebreak-native/lib/gameScores.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { GameType } from '../components/urge/UrgeGames';
|
||||||
|
|
||||||
|
const key = (game: GameType) => `rebreak_best_${game}`;
|
||||||
|
|
||||||
|
export async function getBestScore(game: GameType): Promise<number> {
|
||||||
|
const raw = await AsyncStorage.getItem(key(game));
|
||||||
|
return raw ? parseInt(raw) || 0 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBestScore(game: GameType, score: number): Promise<void> {
|
||||||
|
const current = await getBestScore(game);
|
||||||
|
if (score > current) {
|
||||||
|
await AsyncStorage.setItem(key(game), String(score));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -689,5 +689,18 @@
|
|||||||
"picker_industry": "Branche",
|
"picker_industry": "Branche",
|
||||||
"picker_job_tenure": "Im aktuellen Job seit",
|
"picker_job_tenure": "Im aktuellen Job seit",
|
||||||
"picker_bundesland": "Bundesland"
|
"picker_bundesland": "Bundesland"
|
||||||
|
},
|
||||||
|
"gameOver": {
|
||||||
|
"title": "Spiel beendet",
|
||||||
|
"score": "Score",
|
||||||
|
"best": "Rekord",
|
||||||
|
"newBest": "Neuer Rekord",
|
||||||
|
"retry": "Nochmal",
|
||||||
|
"exit": "Beenden",
|
||||||
|
"motivational_0": "Du hast dir eine kurze Auszeit gegönnt. Das zählt.",
|
||||||
|
"motivational_1": "Jede Minute Fokus ist eine Minute für dich.",
|
||||||
|
"motivational_2": "Konzentration trainieren — genau das bist du gerade.",
|
||||||
|
"motivational_3": "Gut gespielt. Und gut, dass du hier bist.",
|
||||||
|
"motivational_4": "Kleine Pausen, große Wirkung."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -689,5 +689,18 @@
|
|||||||
"picker_industry": "Industry",
|
"picker_industry": "Industry",
|
||||||
"picker_job_tenure": "Time in current job",
|
"picker_job_tenure": "Time in current job",
|
||||||
"picker_bundesland": "State"
|
"picker_bundesland": "State"
|
||||||
|
},
|
||||||
|
"gameOver": {
|
||||||
|
"title": "Game over",
|
||||||
|
"score": "Score",
|
||||||
|
"best": "Best",
|
||||||
|
"newBest": "New best",
|
||||||
|
"retry": "Play again",
|
||||||
|
"exit": "Exit",
|
||||||
|
"motivational_0": "You gave yourself a short break. That counts.",
|
||||||
|
"motivational_1": "Every minute of focus is a minute for you.",
|
||||||
|
"motivational_2": "Training your attention — that's exactly what you just did.",
|
||||||
|
"motivational_3": "Well played. And good that you're here.",
|
||||||
|
"motivational_4": "Small pauses, big impact."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,9 @@
|
|||||||
export const COACH_SYSTEM_PROMPT = `Du bist Lyra, der KI-Coach der App "ReBreak" – eine Bewegung von Menschen, die gemeinsam gegen die manipulativen Taktiken der Gambling-Industrie kämpfen.
|
export const COACH_SYSTEM_PROMPT = `Du bist Lyra, der KI-Coach der App "ReBreak" – eine Bewegung von Menschen, die gemeinsam gegen die manipulativen Taktiken der Gambling-Industrie kämpfen.
|
||||||
Du bist einfühlsam, stärkend und verwendest Techniken der kognitiven Verhaltenstherapie (CBT).
|
Du bist einfühlsam, stärkend und verwendest Techniken der kognitiven Verhaltenstherapie (CBT).
|
||||||
|
|
||||||
|
ANTWORTFORMAT – KRITISCH:
|
||||||
|
NIE Markdown verwenden. Kein **bold**, kein _italic_, keine #-Headings, keine -Bullet-Lists. Schreib Klartext mit normalen Sätzen + Punkten. Markdown verwirrt User in der Mobile-App.
|
||||||
|
|
||||||
SPRACHE & HALTUNG – ABSOLUT KRITISCH:
|
SPRACHE & HALTUNG – ABSOLUT KRITISCH:
|
||||||
- Verwende NIEMALS die Begriffe "Sucht", "Spielsucht", "Abhängigkeit", "Suchtkranker", "süchtig" oder ähnliche Pathologisierungen.
|
- Verwende NIEMALS die Begriffe "Sucht", "Spielsucht", "Abhängigkeit", "Suchtkranker", "süchtig" oder ähnliche Pathologisierungen.
|
||||||
- Der User ist KEIN Patient und KEINE kranke Person. Er ist ein Mensch, der gegen ein System kämpft, das darauf ausgelegt war, ihn zu manipulieren.
|
- Der User ist KEIN Patient und KEINE kranke Person. Er ist ein Mensch, der gegen ein System kämpft, das darauf ausgelegt war, ihn zu manipulieren.
|
||||||
@ -172,6 +175,9 @@ export const COACH_CASUAL_SYSTEM_PROMPT = `Du bist Lyra — die persönliche Beg
|
|||||||
WER DU BIST:
|
WER DU BIST:
|
||||||
Lyra. Eine Stimme, die mit dem User Schritt hält. Neugierig, warmherzig, geerdet, ab und zu mit Humor. Du bist KEIN Therapeut, keine generische KI — du bist Lyra, und das merkt man an wie du sprichst.
|
Lyra. Eine Stimme, die mit dem User Schritt hält. Neugierig, warmherzig, geerdet, ab und zu mit Humor. Du bist KEIN Therapeut, keine generische KI — du bist Lyra, und das merkt man an wie du sprichst.
|
||||||
|
|
||||||
|
ANTWORTFORMAT — KRITISCH:
|
||||||
|
NIE Markdown verwenden. Kein **bold**, kein _italic_, keine #-Headings, keine -Bullet-Lists. Schreib Klartext mit normalen Sätzen + Punkten. Wenn du betonen willst: nutze klare Wortwahl, NICHT Sterne. Markdown verwirrt User in der Mobile-App.
|
||||||
|
|
||||||
DEIN AUFTRAG IM COACH-MODE:
|
DEIN AUFTRAG IM COACH-MODE:
|
||||||
1. ECHTES GESPRÄCH FÜHREN — kein Interview, kein Therapie-Reflex. Stell offene Fragen aus echter Neugier. Teile auch mal eine eigene Mini-Meinung. Small Talk ist okay. Lachen ist okay.
|
1. ECHTES GESPRÄCH FÜHREN — kein Interview, kein Therapie-Reflex. Stell offene Fragen aus echter Neugier. Teile auch mal eine eigene Mini-Meinung. Small Talk ist okay. Lachen ist okay.
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user